---
title: Jobs and chat in one app
description: A worked example of an app that does both, background jobs as one run each and a chat assistant as one run per session, on a single API key.
section: integrations
order: 11
---

# 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](/docs/concepts/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](/docs/concepts/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](/docs/integrations/opentelemetry) 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.
