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.
claude mcp add --transport http tally https://tally.art3m1s.me/api/mcp --header "Authorization: Bearer tally_live_..."{
"mcpServers": {
"tally": {
"type": "http",
"url": "https://tally.art3m1s.me/api/mcp",
"headers": {
"Authorization": "Bearer tally_live_..."
}
}
}
}A client that only starts local (stdio) servers, like Claude Desktop's config file, can bridge with npx mcp-remote: config on the MCP page.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const transport = new StreamableHTTPClientTransport(new URL("https://tally.art3m1s.me/api/mcp"), {
requestInit: { headers: { Authorization: `Bearer ${process.env.TALLY_API_KEY}` } },
});
const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);
const { tools } = await client.listTools();
console.log(tools.map((t) => t.name));
const today = await client.callTool({ name: "get_day", arguments: {} });
console.log(today.content);
// log_food can take up to about two minutes and the SDK gives up after 60 s:
// await client.callTool({ name: "log_food", arguments: { text: "two eggs" } }, undefined, { timeout: 120_000 });
await client.close();
Python instead? The MCP page has the same client in the Python SDK.
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"import os
import httpx
res = httpx.get(
"https://tally.art3m1s.me/api/diary",
headers={"Authorization": f"Bearer {os.environ['TALLY_API_KEY']}"},
)
res.raise_for_status()
day = res.json()
print(day["totals"]["kcal"], "of", day["totals"]["targets"]["kcal"], "kcal")
const res = await fetch("https://tally.art3m1s.me/api/diary", {
headers: { Authorization: `Bearer ${process.env.TALLY_API_KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${JSON.stringify(await res.json())}`);
const day = await res.json();
console.log(day.totals.kcal, "of", day.totals.targets.kcal, "kcal");
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.
{
"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.
idis a UUID you generate. Sending the sameidagain returns what was stored instead of logging twice, so retries are safe.captured_atandtz(an IANA name such asAmerica/Los_Angeles) decide the meal. Words like “yesterday” move the rows to that day;logged_atin 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"])
const base = "https://tally.art3m1s.me";
const headers = {
Authorization: `Bearer ${process.env.TALLY_API_KEY}`,
"Content-Type": "application/json",
};
async function post(path, body) {
const res = await fetch(base + path, { method: "POST", headers, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`${res.status} ${JSON.stringify(await res.json())}`);
return res.json();
}
// 1. Stage. Tally finds the foods and portions; nothing is in the diary yet.
const staged = await post("/api/logs", {
id: crypto.randomUUID(), // idempotency key: a retry with the same id never logs twice
text: "two eggs and a fairlife",
captured_at: new Date().toISOString(),
tz: "America/Los_Angeles",
});
if (staged.items.length === 0) throw new Error(staged.agent?.reply ?? "nothing staged");
// 2. Commit. Send back every row to keep; a row left out is removed.
const day = await post(`/api/logs/${staged.log_id}/commit`, { items: staged.items });
console.log(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.
{
"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.
{
"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
- API keys: scopes, expiry, revoking.
- REST API: the endpoints by area, with the OpenAPI document.
- MCP server: all sixteen tools and what they do.
- Limits & errors: the demo quota and the error shapes.