Jobs and chat in one app
Most apps that grow an AI feature end up with both shapes: background work that runs to completion, and a chat the user talks to. They want reporting in the same place but they are not the same thing, and modelling them identically makes one of them read badly.
This is a real example, a family meal planner running on a Cloudflare Worker, with four agents on one team key.
The two shapes
| Jobs | Chat | |
|---|---|---|
| Agents | recipe-import, meal-research |
recipe-chat, assistant |
| A run is | one piece of work | one session |
| Ends when | the work finishes | the user leaves, or the idle timeout |
| Reports | llm_call events, then finish with the result |
llm_call and tool_call events per turn |
| Typical length | seconds | minutes to hours |
Jobs: one run each
Reading a recipe out of a photo starts, does the work, and ends with something to show for it. Open, report the model calls, then declare the outcome:
POST /v1/runs {"id": "run_eu_…", "agent": "recipe-import",
"started_at": "…", "goal": "Read a recipe out of a photo",
"feature": "photo"}
POST /v1/runs/…/events {"events": [{"external_id": "evt_eu_…", "type": "llm_call", …}]}
POST /v1/runs/…/finish {"status": "success",
"output": {"title": "Chorizo traybake", "ingredients": 9},
"scores": {"found": true}}
feature separates the variants photo, link and text without three near-identical
agents, so they stay comparable but still filter apart. output is what the job
produced; it is what the quality checks read, and an empty one is a job that did nothing.
Chat: one run per session
The assistant is the same account, the same key, a different shape. The run opens on the first message and every turn appends to it. See Conversations for the full treatment:
POST /v1/runs {"id": "run_eu_<session>", "agent": "assistant",
"started_at": "…", "idle_timeout_seconds": 900,
"idle_timeout_status": "success"}
POST /v1/runs/…/events turn 1's llm_call and tool_call events
POST /v1/runs/…/events turn 2's events
POST /v1/runs/…/finish {"status": "success"} when the user closes the chat
Two details make this work from a stateless request handler:
Re-post run-start on every turn. It is idempotent on id and started_at, so
there is no "have I opened this session yet?" lookup and no session table.
Derive the run id from the session id. The app mints a session id whose first 12
hex characters are the start time in milliseconds: the same scheme
Agent Ping's own identifiers use, so started_at is
recomputed identically on every turn, from the id alone. Send a different started_at
on turn two and you get a 409.
Closing a session you cannot see the end of
The chat closes its run when the user taps the close button, and again when the app is
backgrounded. Neither is guaranteed: a closed tab or a flat battery sends nothing. So
the run carries idle_timeout_seconds: 900 and the sweep closes it fifteen quiet
minutes later, at its last activity.
Belt and braces is the right posture. The explicit close makes the dashboard immediate; the timeout catches everything else.
Report the tools, not just the model
The assistant uses tools, so one turn is often several model calls: ask the model, it
requests a tool, run it, ask again with the result. Three messages produced six
llm_call events, which looks like duplication until the tool calls are there too:
LLM_CALL claude-sonnet-4-6 · 1727 → 69 "call get_week"
TOOL_CALL get_week free, instant
LLM_CALL claude-sonnet-4-6 · 2043 → 181 the reply
Watch the input tokens climb across a session, 1670 then 2043 then 2298, as the history is re-sent each turn. That is why cost per session is the number to watch, not cost per turn.
What this app deliberately does not do
It does not use OpenTelemetry. It was not already emitting traces, so adopting OTel purely to report here would have meant learning spans and parent span ids to express something three POSTs already say. If you are already on OTel, point your exporter at us instead.
It does not send tool arguments or results. Those are the family's meal plans and dislikes. The tool name and whether it failed are enough to read the timeline.
It does not report from the browser. Everything goes from the server, where the API key lives.