/ For operators running coding agents in more than one repo at once

How do I see what all my AI coding agents are working on?

Give every repo one status row in a shared database, have the agent write to it at the end of each unit of work, and put one read-only page in front of the table. The agents report; the page never touches them. This is the table, the endpoint, the posting lifecycle and the one writing rule that decided whether any of it stayed accurate, taken from the version running across ten repos today.

Published September 13, 2026/14 min read/Free to read, no signup

The short version

  • One row per repo, not per session. The repo is the thing that persists between windows.
  • Agents write, the page reads. One bearer-authed POST is the only write path.
  • Keep the state list to one word each: idle, deploying, working, review, blocked, done, error.
  • Put the free-form detail in a jsonb column so a new field never costs a migration.
  • The accuracy problem is a writing standard, not a schema problem. “Done means the session is over” is the rule that fixed it.
  • Decide staleness when you read, never by rewriting the row.

The problem is not the agents. It is that nothing writes anything down.

A coding agent knows exactly what it is doing while it is doing it. It knows the repo, the branch, the task it picked up, whether it is stuck, and what it is waiting on you for. Then the window closes and every bit of that is gone. Nothing stored it and nothing shared it.

In one repo that costs nothing, because you were sitting there. Across ten it costs you the morning. I run sessions in ten: the internal dashboard, the marketing site, four client builds, two products and two side systems. The question I want answered before I open anything is small. Which of these is mid-task, which finished overnight, and which is stuck on something only I can clear.

A terminal cannot answer that. It shows one session, and only while it is open. Neither can the git log: a commit tells you something landed, not that the agent is now blocked on a credit top-up. The missing piece is not intelligence, it is a place to write state down and a habit of writing to it. It is the same order of work as mapping a process before you automate it: decide what gets recorded, then build the thing that records it.

The shape of the fix: agents write, one page reads

One row per repo. Not per session, per repo. Sessions come and go several times a day and you do not want a feed of them; the repo is the stable thing, so the repo gets the desk and whichever session is running writes to it.

The agent posts its own state at the end of each unit of work: a commit, a fix, a decision, getting blocked. The page in front of the table only ever reads. That split is the entire design. One write path, bearer authenticated, called by agents in other repos. One read path, a page that queries the table directly and never writes back. No daemon watching processes, no websocket, no agent supervising another agent.

Two tables carry it. agent_status holds current state, one row per repo. agent_events is append-only history, so a desk can show what happened before the state it is in now.

The table

The current-state table. One row per repo.
create table if not exists agent_status (
  id           uuid primary key default gen_random_uuid(),
  key          text unique not null,          -- slug, e.g. 'swiftstream-website'
  name         text not null,                 -- display name on the tile
  zone         text not null,                 -- which side of the business
  status       text not null default 'idle'
    check (status in ('idle','deploying','working','review','blocked','done','error')),
  progress     int  not null default 0 check (progress between 0 and 100),
  summary      text null,                     -- one line: what it is doing
  context      jsonb not null default '{}'::jsonb,
  infra        text[] not null default '{}',  -- supabase | vercel | stripe | ...
  sort         int  not null default 100,     -- fixed display order
  last_update  timestamptz not null default now(),
  created_at   timestamptz default now()
);

create table if not exists agent_events (
  id         uuid primary key default gen_random_uuid(),
  key        text not null references agent_status(key)
               on update cascade on delete cascade,
  kind       text not null default 'note',
  text       text not null,
  meta       jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);

create index if not exists agent_events_key_created_idx
  on agent_events (key, created_at desc);

Why each column is shaped the way it is.

ColumnWhat it holdsWhy it is like that
keyThe repo's fixed slugIt is the URL parameter and the foreign key for history. Pick it once and never rename it casually.
statusOne word from a checked listA free-text status column turns into forty synonyms for stuck. The check constraint is what keeps the board readable at a glance.
progress0 to 100Rough by design. It answers “nearly there or barely started”, nothing finer.
summaryOne plain sentenceThe line printed on the tile. If it needs two sentences the detail belongs in context.
contextFree-form jsonbCurrent task, next step, blocker, branch, last commit, open issues, pending items, links. Free-form means a new field costs zero migrations, which is why agents actually fill it in.
infraArray of service namesThe edge list for a map of which repo touches which shared service. Answers “what breaks if this provider goes down”.
sortFixed display indexTiles keep their position when a status flips. Ordering by status makes the board jump every time an agent posts, and a board that jumps is one you stop trusting.
last_updateTimestamp of the last postThe only input to the staleness rule further down. Never edited by anything except a post.

The jsonb column earns its place. The typed shape read by the page is declared once in the code that renders it, and everything an agent posts that is not in that shape is kept verbatim under an extra key rather than dropped. On read, every known field is coerced: strings trimmed and capped, arrays filtered to well-formed items, links accepted only on an allow-list of keys and only when they parse as http or https. Nothing an agent posts reaches the page unvalidated, and nothing it posts is silently lost.

The endpoint

One route. POST to write, GET with the same bearer to list every row for a script. The token lives in each repo’s env file, and the comparison is timing safe, which costs one function and removes a whole class of guessing attack.

What an agent posts at the end of a unit of work.
curl -s -X POST "https://your-dashboard.example.com/api/agent-status" \
  -H "Authorization: Bearer $AGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "swiftstream-website",
    "status": "working",
    "progress": 62,
    "summary": "Resource library shipped. Email capture wired to contacts.",
    "context": {
      "current_task": "Public guide pages plus the signup route",
      "next_step": "Point the Instagram flow at the hosted URL",
      "phase": "Phase 2 / Build",
      "branch": "main",
      "pending": [{ "text": "Add the Supabase keys in Vercel", "kind": "action" }]
    },
    "event": { "kind": "working", "text": "What changed" }
  }'
  • { ok: true } means it posted.
  • 401 means the bearer is wrong. Check the env file in that repo, not the endpoint.
  • 503 means the table is not there yet. Tell the human and carry on working. A missing dashboard must never stop the actual work.
  • context merges with what is stored, so a post carries only what changed. An agent that has to resend its whole state to update one field will stop posting.

The same request appends a history row, which is what makes a desk drillable later. Current state answers what is happening. History answers what happened while you were asleep, and those are different questions.

The posting lifecycle, in four moments

  1. Session start: post deploying

    One line saying what this session is about to do. The state exists so a desk can show a session that has woken up but has not picked up real work yet. Without it, every session starts by looking idle while it reads the codebase.

  2. First real task: post working

    Set context.current_task and context.next_step. Those two fields are what let you re-enter the repo hours later without reading a transcript.

  3. After every unit of work: post again

    A commit, a fix, a decision, a dead end. New summary, new progress, and only the context fields that changed. This is the habit the whole board depends on.

  4. Session end: post done, or blocked with the reason

    blocked and error must carry context.blocker set to what is actually being waited on. A blocked desk with no reason is worse than an idle one, because it looks like it needs you and cannot tell you why.

Seven states, one word each. idle is nothing running. deploying is a session that has started. working is real work in flight. review is waiting on you for a decision or a check. blocked is waiting on something outside the room: credits, a vendor, a client. done is the session is over. error is it broke.

The split between review and blocked matters more than it looks. review is your queue and you can clear it today. blocked is somebody else’s queue and clearing it means chasing. Collapsing them into one state gives you a list you cannot act on.

The rule that made it work: done means the session is over

The first version was accurate for a day. Then the board filled with finished desks while four sessions were still typing, because agents were posting done after every push. Reasonable reading of the word. Wrong for this board.

The fix was one sentence in every repo’s agent instructions file, checked into that repo, so every session reads it before it does anything. No code changed. This is the part people skip when they build one of these: every field on the board is written by an agent, so the accuracy of the board is a writing standard, not a schema constraint. The database can enforce that status is one of seven words. Only the instruction can enforce that the word means the same thing in every repo.

Same reasoning behind the rest of the standard: summary is one plain sentence, pending items are things waiting on a human and nothing else, and a new repo key is agreed rather than invented. Vague rules produce vague boards.

Staleness is a read-time decision, never a write

Agents crash. Laptops close mid-task. A desk still reading working after twenty hours is a lie the board is telling you, and one lie is enough to make you stop trusting the other nine tiles.

The rule that fixed it: if last_update is older than twenty-four hours, working, deploying, review and done display as idle with a stale marker. blocked and error keep their state, because they still need you, but they get the marker too. idle is never stale, because idle is not a claim about anything.

The bug worth knowing about before you build it

Every desk sat on its seeded state and no post could move it. The error was a not-null violation on a column the post was never trying to change.

The cause: the write used one upsert for both new and existing rows. Through PostgREST that becomes insert ... on conflict do update, and Postgres checks not-null on the tuple being inserted before it evaluates the conflict clause. name and zone are not null with no default, so a partial body from an existing repo failed with 23502 before the update could ever run. The documented behaviour, that an existing key only has to send what changed, was impossible.

Branch on whether the row exists. Same body either way.
const existing = await rest<{ key: string }[]>(
  `agent_status?key=eq.${encodeURIComponent(key)}&select=key&limit=1`,
);

if (existing.length) {
  // Partial update: only the fields this post carries.
  await rest(`agent_status?key=eq.${encodeURIComponent(key)}`, {
    method: 'PATCH',
    body: JSON.stringify(patch),
  });
} else {
  // New desk: name and zone are required here, and only here.
  await rest('agent_status', { method: 'POST', body: JSON.stringify(row) });
}

Worth stating plainly because the symptom points at the wrong thing. The message named a column the agent had not touched, so the first instinct is to make the agent send more fields, which is the opposite of what you want.

How to build this in your own stack

  1. Create the two tables

    Current state keyed by repo slug, and an append-only events table with a foreign key back to it. Cascade on rename and delete so a renamed repo does not orphan its history.

  2. Seed one row per repo before anything posts

    A board that starts empty gives you nothing to check the endpoint against. Seed every repo you work in with its real slug, and backdate last_update so nothing shows as active until an agent genuinely posts.

  3. Add one write endpoint, bearer authenticated

    POST upserts the row and appends an event. GET with the same bearer lists every row. Compare the token in constant time, and branch between patch and insert as above.

  4. Put the posting rule in every repo’s agent instructions file

    The lifecycle, the state list, the meaning of done, and the repo’s own slug and token variable. Checked in, so it applies to every session in that repo without anyone remembering to say it.

  5. Render one read-only page

    A tile per repo showing state, summary and time since last post. One list of everything waiting on a human, pulled from the pending items across every row. History behind a click. Nothing on that page issues a write.

  6. Apply the staleness rule on read

    Downgrade the display state past twenty-four hours, mark it, and leave the stored row alone.

A working version is an afternoon. Most of the remaining effort is not code: it is deciding what the seven words mean and writing that down where the agents will read it. That written standard is the part that survives, and it is the same piece that makes an AI operating system hold together once more than one thing is running inside it.

What you get once it is running

The morning question is answered in one look, which was the whole point. Underneath that, two things turned out to matter more than expected.

  • One pending list across every repo. Each agent posts what it is waiting on a human for, so the board can show every one of those in a single column. That list is the actual work queue, and no terminal was ever going to produce it.
  • History per repo. Coming back to a build after two weeks, the last twenty events answer what state it was left in faster than reading the code does.
  • A display toggle that swaps real names for neutral labels, so the board can go on a screen share without exposing who the work is for.

And the honest limit: the board is exactly as accurate as the last post. An agent that dies without posting leaves a stale desk, which is why the staleness rule exists and why it marks rather than hides. It reports on work, it does not manage it. That is a smaller promise than most dashboards make, and it is the reason this one is still accurate. If you would rather have the mapping and the build done with you than from a page, that is what the consulting work is.

All guidesWritten by Cercie Florano, SwiftStream

Want this mapped and built inside your business?

Book a discovery call