---
title: "Anatomy of a ticket that runs itself"
description: "Between the moment I create a ticket in KittyClaw and the moment Claude starts working, three seconds pass. Here's what happens in the gap."
date: 2026-04-26T10:03:00+02:00
tags: ["KittyClaw", "Tooling", "AI", "Claude Code", ".NET"]
---

In the [previous post](/blog/kittyclaw-from-kanban-to-harness), 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.

```json
{
  "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:

```json
{
  "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:

```csharp
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**.

```json
{
  "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:

```json
{
  "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](https://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](https://discord.gg/4MVPfw9wTQ)**
