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.