Get started

Your food diary, from your own code.

The Tally app runs on a plain HTTP API. A personal API key lets your scripts and AI agents call the same endpoints: read your days and goals, and log food the way the app does, staged first and saved only when you commit.

Get started

Every endpoint lives under https://tally.art3m1s.me/api. Authenticate each request with a personal key in the Authorization header: Authorization: Bearer tally_live_…. A key looks like tally_live_<keyId>_<secret> and acts as the account that created it. Create one in the app under Settings → API keys; it is shown once, so copy it then. More on keys and scopes.

Demo accounts can create keys too. A key carries its account's limits, so a demo key can log once a day, like the app. Limits.

Connect an agent over MCP

Tally runs a Model Context Protocol server at https://tally.art3m1s.me/api/mcp. Pick your client, paste your key where it says tally_live_..., and your agent can answer “what did I eat today?”. A read-only key gets the read tools only.

Set up Tally MCP

Give an AI agent your diary: it can read your days and goals, and with a read & write key, log food for you.

terminalbash
claude mcp add --transport http tally https://tally.art3m1s.me/api/mcp --header "Authorization: Bearer tally_live_..."

Using another MCP client? Point it at https://tally.art3m1s.me/api/mcp (Streamable HTTP) and send Authorization: Bearer tally_live_… with every request.

Make your first request

Read today's diary. With no ?date=YYYY-MM-DD it returns today in the timezone set in your Tally settings.

curl https://tally.art3m1s.me/api/diary -H "Authorization: Bearer $TALLY_API_KEY"

The response lists the day's entries under all four meals (empty ones included) and the day's totals against your targets. Targets read as zero until you set goals.

200 OK · GET /api/diaryjson
{
  "date": "2026-09-23",
  "meals": {
    "breakfast": [
      {
        "id": "0f6b2c8e-4d1a-4c3b-9e7f-2a5d8c1b6e40",
        "meal": "breakfast",
        "food": {
          "id": "2d4f8a61-9c3e-4b7a-8f10-6e2b5c9d1a37",
          "name": "Egg, whole, cooked, hard-boiled",
          "brand": null
        },
        "serving": { "id": "93437", "description": "1 large" },
        "qty": 2.0,
        "macros": { "kcal": 155.0, "protein_g": 12.58, "carb_g": 1.12, "fat_g": 10.61, "fiber_g": 0.0 },
        "logged_at": "2026-09-23T15:15:00Z",
        "grams": 100.0,
        "source": "fdc",
        "web_source": null,
        "estimated": "none"
      }
    ],
    "lunch": [],
    "dinner": [],
    "snack": []
  },
  "totals": {
    "kcal": 155.0,
    "protein_g": 12.58,
    "carb_g": 1.12,
    "fat_g": 10.61,
    "targets": { "kcal": 2200.0, "protein_g": 160.0, "carb_g": 220.0, "fat_g": 70.0 }
  }
}

Call the API from a server, a script or an agent, not from a web page: the API sends no CORS headers, and a key inside a page is a key anyone can read.

Log food

Logging is two calls, stage then commit, the same flow the app uses. First POST /api/logs with what you ate in words. Tally works out the foods, portions and meal, looks the foods up, and returns staged rows. Nothing is in your diary yet. Then POST /api/logs/{log_id}/commit with the rows you want to keep.

  • id is a UUID you generate. Sending the same id again returns what was stored instead of logging twice, so retries are safe.
  • captured_at and tz (an IANA name such as America/Los_Angeles) decide the meal. Words like “yesterday” move the rows to that day; logged_at in the response says where.
  • The call waits while Tally looks the foods up, which can take up to about two minutes. Give your HTTP client a long timeout.
import os
import uuid
from datetime import datetime, timezone

import httpx

tally = httpx.Client(
    base_url="https://tally.art3m1s.me",
    headers={"Authorization": f"Bearer {os.environ['TALLY_API_KEY']}"},
    timeout=130,  # a log looks every food up before it answers
)

# 1. Stage. Tally finds the foods and portions; nothing is in the diary yet.
staged = tally.post("/api/logs", json={
    "id": str(uuid.uuid4()),  # idempotency key: a retry with the same id never logs twice
    "text": "two eggs and a fairlife",
    "captured_at": datetime.now(timezone.utc).isoformat(),
    "tz": "America/Los_Angeles",
}).raise_for_status().json()

if not staged["items"]:  # nothing staged; agent.reply is usually a question for you
    raise SystemExit(staged["agent"]["reply"] if staged["agent"] else "nothing staged")

# 2. Commit. Send back every row to keep; a row left out is removed.
day = tally.post(
    f"/api/logs/{staged['log_id']}/commit", json={"items": staged["items"]}
).raise_for_status().json()
print(day["kcal"], "kcal today", day["errors"])

The stage response, trimmed. At commit, only rows with status: "resolved" go into the diary. Any other row you send back stays on the log, uncommitted; any row you leave out is removed. When the agent needs to ask you something first, items is empty, agent.reply is the question and agent.options may list answers.

200 OK · POST /api/logsjson
{
  "log_id": "7c0e5a0e-2f4b-4f7e-9d0a-5b8f1f3c2a91",
  "meal": "breakfast",
  "items": [
    {
      "item_index": 0,
      "phrase": "eggs",
      "status": "resolved",
      "food": {
        "id": "2d4f8a61-9c3e-4b7a-8f10-6e2b5c9d1a37",
        "name": "Egg, whole, cooked, hard-boiled",
        "brand": null
      },
      "serving": { "id": "93437", "description": "1 large" },
      "qty": 2.0,
      "macros": { "kcal": 155.0, "protein_g": 12.58, "carb_g": 1.12, "fat_g": 10.61, "fiber_g": 0.0 },
      "meal": "breakfast",
      "confidence": "high",
      "estimated": "none"
    },
    {
      "item_index": 1,
      "phrase": "fairlife",
      "status": "resolved",
      "food": {
        "id": "9a3e7b52-1f6c-4d8e-b0a4-73c5e2d91f68",
        "name": "Ultra-filtered 2% milk",
        "brand": "fairlife"
      },
      "serving": { "id": "label", "description": "1 cup (240 ml)" },
      "qty": 1.0,
      "macros": { "kcal": 120.0, "protein_g": 13.0, "carb_g": 6.0, "fat_g": 4.5, "fiber_g": null },
      "meal": "breakfast",
      "confidence": "high",
      "estimated": "none"
    }
  ],
  "logged_at": null,
  "saved_meal": null,
  "note": null,
  "agent": {
    "reply": "Two eggs and a fairlife, staged for breakfast.",
    "weight": null,
    "options": null,
    "discarded": null,
    "updated": null
  }
}

The commit answers with the day's new totals. A row the commit refused appears in errors with its item_index; the rest are saved.

200 OK · POST /api/logs/{log_id}/commitjson
{
  "kcal": 275.0,
  "protein_g": 25.58,
  "carb_g": 7.12,
  "fat_g": 15.11,
  "targets": { "kcal": 2200.0, "protein_g": 160.0, "carb_g": 220.0, "fat_g": 70.0 },
  "errors": [],
  "saved_meal": null,
  "totals_day": "2026-09-23"
}

Where next