In the previous post, I told how KittyClaw shifted from passive board to orchestrator. Create a ticket, thirty seconds later Claude is on it. This post goes into the detail: what happens in between?

I'll follow a ticket from creation to execution, stopping on the five design decisions that make the system robust — or that saved it when it wasn't yet.


1. The trigger: polling, not webhooks

When the ticket is created, nothing happens right away. KittyClaw doesn't "react" to the creation event — it looks at the board every 30 seconds and decides what to do.

{
  "trigger": {
    "type": "ticketInColumn",
    "columns": ["Todo", "InProgress"],
    "assigneeSlug": "programmer",
    "seconds": 30
  }
}

I hesitated one evening between polling and events. Events are "cleaner" (no latency, no wasted CPU). But polling has three advantages I found decisive:

  • It survives anything. The engine crashes, restarts, misses an event — next tick, it catches up.
  • It's naturally rate-limited. Even if I create 50 tickets at once, dispatch happens in 30s windows. No thundering herd.
  • It's the same model as the old dispatcher.mjs I was replacing. Zero regression risk.

For rare events (a git commit, a Done transition) I have specialized triggers (gitCommit, statusChange) that poll at 60s or 30s. For events you can't afford to miss, I have boardIdle and agentInactivity that watch for absence of activity — impossible to model as events.

Rule of thumb: when in doubt between polling and webhooks in a solo-dev system, pick polling. You debug with a log file and sleep 30, not ngrok plus a retry stack.


2. Conditions: a gate before dispatch

Not every ticket matched by the trigger is dispatched. Between trigger and action sits a list of conditions to pass:

{
  "conditions": [
    { "type": "minDescriptionLength", "length": 50 },
    { "type": "labels", "labels": ["release"] }
  ]
}

On HolybotsRisingApps for example, the publisher automation fires only if the ticket carries the release label and either app:borne or app:gamemaster. The developer only takes tickets with descriptions of at least 50 chars — to keep a hastily-thrown "fix the bug" ticket from dispatching before the producer grooms it.

Conditions are the right place to encode board quality policies. They don't replace human judgment, but they kill 80% of the cases where I'd launch an agent for nothing.


3. Injected context: minimal, deliberately

When dispatch fires, KittyClaw launches a claude process with a prompt. The prompt is a lot simpler than you might expect:

{contents of skills/programmer.md}

Focus on ticket #42: {ticket title}

That's it. No description, no comments, no sub-tickets, no repo context.

Why so little? Because the agent already has the tools to fetch them. It can hit the KittyClaw API (GET /api/projects/{slug}/tickets/{id}), read comments, mentions, linked tickets, sub-tickets, on demand. And it runs inside the project's WorkspacePath — it can git log, read files, do its own research.

Every piece of context you stuff into the prompt is context you pay for in tokens on every run, including when it's not needed. Letting the agent fetch on demand is 10× cheaper and 10× fresher.

The skill file (skills/programmer.md) contains the craft rules: how to read a ticket, how to post comments, how to handle sub-tickets, when to push back for review. It's the persona — stable, versioned in the repo, editable without touching KittyClaw's code.


4. Sessions: memory between runs

If I re-dispatch the same agent to the same ticket ten minutes later, I don't want it starting from zero. It must remember what it read, did, concluded last run.

Claude Code supports --resume <session-id> natively. KittyClaw hooks into that:

var existingSessionId = _sessions.GetSessionId(workspace, agentName, ticketId);
var sessionId = existingSessionId ?? Guid.NewGuid().ToString();
var isResume = existingSessionId is not null;

The key is (workspace, agent, ticket). Each combination has its own persistent session, stored in .agents/channel/dispatch-state.json. When the agent comes back to the ticket, it's --resume and the prompt becomes:

The owner has posted feedback on ticket #42: {title}
Read ALL owner comments on this ticket and address them.

No need to resend the skill — it's in the session. You just tell it: "come back, there's new input, take a look".

Point that cost me a morning: sessions must survive KittyClaw restarts. I started keeping them in memory, then a dotnet watch nuked them all. Since then everything is persisted to disk immediately, in the same JSON file the old dispatcher.mjs used. Bonus: my projects still running on dispatcher.mjs can migrate without losing sessions.


5. Concurrency: keeping agents from stepping on each other's files

Real problem: if programmer and 3d-artist run at the same time on the same repo, they collide. Two processes editing files, a git status that no longer knows who did what.

Solution: concurrency groups.

{
  "actions": [{
    "type": "runClaudeSkill",
    "skillFile": "skills/programmer.md",
    "concurrencyGroup": "code",
    "mutuallyExclusiveWith": ["producer-commit"]
  }]
}

Rules are simple:

  • concurrencyGroup: at most one active run per group. Every agent that touches code sits in "code"programmer, 3d-artist, technical-artist, qa-tester, documentalist, code-janitor. One at a time.
  • mutuallyExclusiveWith: while one run is active, it blocks the listed groups. HolybotsRisingApps' producer-commit, which commits/PRs a finished ticket, blocks code, producer, and publisher — because it needs a stable repo to commit cleanly.
  • Implicit dedup: no two active runs on (agent, ticket). If you post a comment while a run is live, it'll be picked up next poll, not in parallel.

These three rules eliminated 100% of the file-conflict issues I used to see with the old JS dispatcher. Not because the JS was bad — because the concurrency rules lived in if/else code instead of being declarative.


6. The budget: the fuse you hope never blows

Last piece, optional but precious:

{
  "dailyBudgetUsd": 70
}

KittyClaw tracks run cost (via the cost-log.jsonl Claude Code emits natively). As soon as the day's sum crosses the threshold, all non-CEO dispatches are blocked. Only a supervising agent, if any, can still run to decide what to do.

I've never hit this threshold in normal conditions. But I hit it once because of an infinite loop between two agents ping-ponging comments at each other. The budget cut out at $70, I woke up the next morning with a manageable bill and a lesson learned. Without the budget, it could have been much worse.


Recap: the full cycle

What actually happens, in order, when I create a ticket:

  1. t = 0s: I create the ticket via UI or API. Status Todo, assignee programmer.
  2. t ≈ 15s: the next ticketInColumn tick (poll every 30s) spots the ticket.
  3. t ≈ 15s: conditions are evaluated (description length, labels). All pass.
  4. t ≈ 15s: the moveTicketStatus action flips the ticket to InProgress.
  5. t ≈ 15s: the engine checks concurrency groups. Nobody's on code — go.
  6. t ≈ 15s: session lookup. No existing session for (workspace, programmer, #42) — new session created.
  7. t ≈ 16s: claude process launched with --print --verbose --output-format stream-json --session-id <uuid>, prompt = skill + focus.
  8. t ≈ 17s: stream events (assistant, tool_use, tool_result) flow into the run panel, visible from the ticket.
  9. t ≈ N minutes: Claude is done. It posted comments, maybe created sub-tickets, maybe moved the ticket to OwnerReview. The run is logged with its cost.
  10. t = N + 30 min of silence on Done: if the ticket moves to Done and stays quiet for 30 min, the evaluator wakes up and reviews.

This pipeline is broadly what I had before in JS with dispatcher.mjs. But:

  • It's declarative (automations.json) instead of imperative (code).
  • It's integrated with the board (runs are visible from tickets).
  • It survives crashes (sessions and state persisted).
  • It applies to every project at once (one engine, N configs).

What this teaches me about automation

The more automations I add, the more I realize the right granularity is neither "code everything" nor "configure everything". It's a collaboration between:

  • A simple, generic engine in code. A handful of trigger types, action types, concurrency and sessions.
  • Declarative per-project configs. That's where policy lives: who dispatches when, who has what budget, who blocks whom.
  • Skills in Markdown, versioned in the project repo. That's where persona and craft live.

The engine knows nothing about video games, docs, or CI. The skills know nothing about polling or concurrency groups. Configs bind the two.

This separation makes adding a new agent trivial: write a skill, add 10 lines to automations.json, live. No deploy, no restart — the POST /api/projects/{slug}/automations/reload endpoint does it hot.


What's next

On Aekan, 13 agents run on this system. On HolybotsRisingApps, 6 — including a specific release workflow (publisher gated by label, producer-commit mutually exclusive with the rest). On Lain, 15 with an active daily budget and a CEO that wakes on idle.

Every project has its own rules. The engine doesn't change.

And maybe that's the right definition of a harness: a generic infra on top of which each project writes its own choreography.


The code

Everything is open source:

github.com/Ekioo/KittyClaw

The engine lives in KittyClaw.Core/Automation/. The migration guide from a dispatcher.mjs is in docs/automation-migration.md.

To discuss, ask questions, or share your own agent-orchestration patterns — the Discord:

Join the Discord