Two features from the top of improv.md, drawn so they can be built from. The log
screens use real entries from /api/operations on the running Space — real names, real counts,
real timings — because that log already exists and this is mostly a view over it. The cron screens are invented.
Nothing here is wired up; every panel is a picture.
A cron sends a prompt to an agent on a schedule. Six fields. The job has its own name, separate from the agent's — one agent can carry several jobs, and the list has to be readable when it does. Run on restart exists because the Space sleeps: a job that only trusts the clock quietly does not happen.
The list is what gets used daily: what is due, how the last run went, and three actions per row. Run now fires it outside the schedule — the "after a restart" case. Stop switches the job off but keeps it, so it can be turned back on. Delete removes it. Stopping and deleting are different acts and both are one click away.
| Job | Agent | Type | Interval | State | Next | Last run | Actions |
|---|---|---|---|---|---|---|---|
| nightly deploy check | nightly-index | Claude Code | every day 09:00 · on restart | running | tomorrow 09:00 | ok 4m 18s · today 09:00 | Run nowStopDelete |
| weekly dependency bump | dep-bot | Codex | Mondays 07:00 | running | Mon 07:00 | ok 11m 02s · Mon 07:00 | Run nowStopDelete |
| digest of yesterday | nightly-index | Claude Code | every day 18:00 | stopped | — | ok 2m 40s · Tue 18:00 | Run nowStartDelete |
| inbox triage | triage | Gemini CLI | hourly | running | in 24m | failed no such agent type · Tue 14:00 | Run nowStopDelete |
Agents set these up the way they use everything else, and the call is logged like any other write — so a cron created by an agent appears in the API log below, attributed to that agent.
# create — the agent is made on the first fire if it does not exist POST /api/crons?from=$AM_ID { "name": "nightly deploy check", // the JOB's name "agent": { "name": "nightly-index", "cli": "claude" }, "prompt": "Check last night's deploy log…", "schedule": { "cron": "0 9 * * *", "tz": "Europe/Zurich" }, "runOnRestart": true } → 201 { "id": "cron_7f3a", "next": "2026-08-20T07:00:00.000Z" } # list, with how each one last went GET /api/crons → 200 { "crons": [ { "id": "cron_7f3a", "name": "nightly deploy check", "agent": "nightly-index", "state": "running", "next": "…", "last": { "at": "…", "status": "ok", "durationMs": 258000 } } ] } # fire one by hand — the "after a restart" case POST /api/crons/cron_7f3a/run?from=$AM_ID → 202 { "ok": true, "agentCreated": false } # stop keeps the job; delete removes it PUT /api/crons/cron_7f3a?from=$AM_ID { "state": "stopped" } DELETE /api/crons/cron_7f3a?from=$AM_ID
This half is nearly free: /api/operations already records every write with who made it, what it
hit, the status and how long it took. One line per call, so a screen holds twenty of them. Rows below are real.
| Time | Who | Call | Status | Took | Payload |
|---|---|---|---|---|---|
| 20:45:07 | lvwerra | POST /api/agents/agent-manager-2-93de86/prompt | 200 | 18.4s | prompt · 1,204 chars |
| 20:42:11 | lvwerra | POST /api/sessions/manager-1049f7/input | 200 | 576ms | prompt · 196 chars |
| 20:41:02 | manager | POST /api/agents/am-overview-improv-0c2edd/prompt | 200 | 20.2s | prompt · 2,551 chars |
| 20:38:55 | manager | POST /api/agents/agent-manager-4-ba3fbf/prompt | 200 | 12.0s | prompt · 3,090 chars |
| 16:25:50 | lvwerra | DELETE /api/sessions/sfo-departures-93007d/attachments/att_3b61b6ef | 200 | 6ms | — |
| 16:24:18 | lvwerra | POST /api/files/files-5-a39b19/write | 200 | 41ms | file · 8.2 KB |
| 11:07:33 | manager | POST /api/sessions/nonexistent-xyz/archive | 404 | 3ms | — |
| 10:04:18 | lvwerra | POST /api/sessions/claude-code-3-30bb27/input | 200 | 302ms | prompt · 10 chars |
One lane per agent, time left to right, and the interactions drawn between the lanes. A prompt is an arrow from caller down to target. A finished wait is an arrow back the other way. So the picture shows work going out and attention coming back — which is the thing a flat list cannot show.
Also considered: a hub-and-spoke flow map (shows the fleet's shape, loses time) and a caller × target grid (cheapest, loses both time and any sense of a chain). Lanes win because they carry time and direction at once, which is what "who called who, when" actually asks for.
Why: only writes are logged. operations.js:8 defines
MUTATING = POST, PUT, PATCH, DELETE and line 78 skips everything else, so of the last
193 calls 173 were POST, 20 PUT and zero GET. Waiting is a GET
(/api/agents/:id/wait), so every arrow coming back is missing from the data. Build the
lanes on today's log and you get a picture of work being handed out and nothing ever returning.
The smallest change — one allowlist, one guard, no new storage:
// operations.js — log the reads that mean "A waited for B", nothing else const LOGGED_READS = [/^\/api\/agents\/[^/]+\/wait$/]; const shouldLog = (req) => MUTATING.has(req.method) || (req.method === 'GET' && LOGGED_READS.some((re) => re.test(req.path))); // …and inside record(), drop a wait that carried no news: // wait is a polling loop — only the call that RESOLVED is an event if (req.method === 'GET' && responseBody && responseBody.matched === false) return;
Three details that decide whether it works:
1. Do not log tail. It is called constantly by every open pane; logging it multiplies the log by the polling rate and adds nothing the resolved wait does not already say.
2. wait carries no ?from= today — it is documented as read-only, and the middleware rejects mutating calls without one (400). Logged reads must stay optional on from, or every running watch loop breaks the moment this ships. Record the origin when it is there; when it is not, the entry still says someone finished waiting on B, which draws as a mark on B's lane rather than an arrow.
3. Then ask agents to pass it. Adding ?from=$AM_ID to the wait examples in the shared environment skill is what turns those marks into real arrows. It costs one line of documentation and no code.
Volume: a resolved wait is one entry per finished watch — roughly one per prompt, so this grows the log by about a third, not by orders of magnitude. That is the whole cost of making the headline view honest.
Where each half is easy because something already exists, and where it is genuinely hard.
/api/operations exists and records exactly the fields these mocks show. The list is a settings
tab over data already on disk — no new server work, no new storage. Every row above came from the real
endpoint, which is why the names look like your fleet.
Prompting an agent and creating one are both existing API calls. A cron is a stored line, a timer, and a call the app already makes — plus the honest bits: recording what happened, and being visible when it fails.
A timer cannot fire while the app is down, and the Space sleeps. The answer is the Run on restart toggle plus Run now in the list — no catch-up machinery, and a fire missed while the Space was asleep stays missed.
Two things a later reader should know, both accepted as-is: a prompt sent to an agent that is still working lands in its composer and may be picked up mid-task, and a schedule spends tokens for as long as it exists — an hourly job is 8,760 runs a year whether anyone reads the output or not. Neither gets a guard or a ceiling. The last-run column and the Stop button are the levers.
No rotation, no retention screen. What keeps 193 entries — or 19,300 — usable is filters that match the questions people ask: only failures, only this agent, only prompts. Worth knowing how lopsided it is: 3 of 193 calls failed, so Only failures earns being one tap rather than a search.
Name collisions: if the job names an agent that already exists, this design reuses it — which is what someone setting up a daily job means, and it is written on the form rather than left to be discovered. Missing agent type: the failed row above says no such agent type. Types are installed in the image, so a job can outlive the CLI it names; the failure has to be legible in the list, because that is the one place anyone will look.