DEV Community

Cover image for 944 lines that turn a ticket into a headless Claude Code session
Panth Patel
Panth Patel

Posted on Originally published at panth.vardayinitech.in

944 lines that turn a ticket into a headless Claude Code session

I hand work to Claude Code through tickets, not a terminal. A 944-line orchestrator with zero dependencies turns each ticket into its own headless Claude Code session, and on 27 September I open-sourced it together with the tracker it works in: ticket-tracker, MIT.

I'm Panth, and I lead the software team at Oizom. I dropped out before the AI wave, because I figured the engineering was in solving problems in the real world, and my college made me choose between attendance and real-world applications. This post is about how the orchestrator works and why it is shaped the way it is.

Why tickets and not a terminal

I wanted to give my VM work from my phone, while travelling or waiting in a queue, with no laptop. The first version was a terminal in an app. It grew into a room full of agents, and three things went wrong:

  1. I got attached to one long-running agent, and starting fresh ones was a chore.
  2. The app was a door into the VM. If someone broke in, I would spend a long time cleaning up.
  3. When the VM lost internet, I couldn't even queue a prompt.

Tickets fixed all three. Every ticket gets its own session, so there is no single agent to get attached to. The orchestrator runs on the machine and pulls work from the tracker, so nothing has to connect into that machine. The tracker is hosted apart from it, so I can raise tickets while the VM is offline, and my VM and my PC can take work from the same board.

The loop

An agent is a board member: a profile, a system prompt and a token that acts as that agent on one board. Assign a ticket to it and move the ticket to To do. From there:

To do  ──orch claims──▶ In progress ──agent reports "review"──▶ Review ──owner──▶ Done
                            │  ▲
           agent asks a     │  │ the answer (form or a comment) resumes
           question ────────▼  │ the same session
                          waiting
Enter fullscreen mode Exit fullscreen mode

The orchestrator claims the ticket and starts claude -p in the repo the workspace points at, with the tracker's MCP tools. The agent reads the ticket and its thread, then publishes its plan as a task list on the ticket and marks each item as it goes.

When it needs a decision, it calls ask_question, which puts a form card in the thread, and ends its turn with status: "waiting". It does not poll. The answer, or any comment in the thread, starts the next turn with --resume on the same session, so the agent picks up with its whole history. Moving the ticket back to To do resumes it as rework. Unassigning it, or moving it anywhere else while the agent runs, stops the agent.

The brief every agent gets says it in two lines:

You run headless in [the repo] and nobody is watching your terminal. The owner only sees the ticket, so everything you want them to see goes on the ticket.

The orchestrator owns the ticket's state

The agent gets the tracker's MCP tools, minus the ones that decide where the ticket stands:

// What only the orch may do: the stage, the heartbeat, the inbox, assignment.
const DISALLOWED = ['move_ticket', 'update_ticket', 'assign_ticket', 'create_ticket', 'heartbeat', 'get_events', 'ack_events']
  .map((t) => `mcp__tm__${t}`).join(',');
Enter fullscreen mode Exit fullscreen mode

The agent does the work and reports review, waiting or blocked. The orchestrator moves the ticket, beats, reads the event inbox and posts the receipt. So a ticket only reaches Review after the orchestrator has seen the agent report it done.

The receipt is one message per turn with what the turn cost. Claude Code's total_cost_usd is cumulative across --resume, so the orchestrator stores the last total and posts the difference. Because the agent pushes the cost of each turn, you can see where the money actually went — which ticket, which turn — instead of one number at the end of the month. One ticket of mine reached $402 over nine runs before the app showed any of it; the full story of that fix is in $402 on one ticket.

Health from a heartbeat

The orchestrator beats once a minute while an agent runs, and every fifth beat it re-reads the task list, so the ticket's dot shows progress like 3/12 · Running tests. The board never stores "this agent is dead". It derives health from the last status and the time since the last beat, in one function, recomputed every 15 seconds:

Health When
working state is working and the last beat is under 75 s old
stale state is working and no beat for 75 s: the orchestrator may have died
idle waiting for an answer, or stopped
done finished, ready for review
error stopped with an error, for example a timeout
none no status yet

75 seconds is one missed beat plus a little slack. Only "working" can go stale: idle, done and error are statements that stay true until the next beat. After five minutes of silence the agent's owner gets one notification per silence.

One command layer

The app, the REST API (/v1, OpenAPI 3.1) and the MCP server (/mcp) all run the same commands. Nothing an agent can do is different from what a person can do, and every change is attributed, for example "Owner via Claude (MCP)". Tokens are scoped to one board and act either as you or as one of your agents.

What running it takes

Everything runs on one Firebase project: Firestore, Functions, Storage, Realtime Database, Auth and Hosting. "Self-hosted" here means your own Firebase project on the Blaze plan, not a Docker image. Locally, pnpm dev starts the emulators, seeds demo data and serves the app (Node 22, pnpm 9, Java 21 and the Firebase CLI).

Firebase, because the tracker had to live apart from the machines doing the work: if the VM is offline or the agents are down, I can still raise tickets. From there the app does live reads straight from Firestore, Auth and push come with it, and the orchestrator wakes on a Realtime Database revision stream, so when an agent comes back it picks up where it left off.

A workspace is a folder with workspace.json (repo, board, agent, how many agents at once), a .env with a Worker token for that agent, and brief.md with the project's rules:

node workspaces/ws.mjs new myproject --repo ~/src/myproject --board MYP --agent "Claude MyProject"
# mint a Worker token for that agent in the app (Account › Tokens), put it in workspaces/myproject/.env
node workspaces/ws.mjs up
node workspaces/ws.mjs status
Enter fullscreen mode Exit fullscreen mode

Agents run unattended in your working tree, with permissionMode from workspace.json: acceptEdits in the template, bypassPermissions only if you trust the brief and the repo. Read workspaces/lib/brief.md before you enable one.

What is not in it

At work I've written about per-ticket stacks: a branch in every repo, its own services, its own copy of the dev database and a preview URL per ticket. None of that is in this repo. That code is specific to Envizom and proprietary. Here the agent works in the repo you point it at.

The repo is github.com/Panth977/ticket-tracker; workspaces/README.md has the loop.

If you run Claude Code headless, where does its question go when it needs a decision, and who answers it?

Top comments (0)