Dispatch
AI-native bug ticketing for the app you have not shipped yet
Running against seeded demo data — an invented tenant, invented people, invented bugs.
Someone testing your staging app finds a bug. They click a floating button in the corner, type a sentence, and go back to what they were doing. On the other end, the report lands in a CRT-green inbox, Claude reads it and writes a fix plan, Claude Code opens the pull request, and the reporter gets an email when it merges and another when it deploys. Nobody wrote a Jira ticket.
That is the whole product. Dispatch is a multi-tenant SaaS — each customer at their own subdomain — and it is 36 models, 21 policies, 76 services, and 272 specs of making that loop trustworthy enough to leave running.
The pipeline is a state machine, not a pile of callbacks
A ticket moves through thirteen states — inbox, planning, needs-input, ready-to-develop, building, ready-for-review, resolved, deployed, plus five terminal ones for the tickets that turn out to be duplicates, spam, or no longer true. The transitions are AASM; the consequences are not.
Every side effect — the Slack Block Kit message, the reporter email, the outbound webhook, the SLA timestamp — lives in one TicketTransitions service. A transition triggered by a GitHub merge webhook, by a developer clicking a button, and by an AI job finishing all run the same fan-out, because there is only one of it. Alongside it, an append-only TicketEvent log records what happened and who caused it, which is what you actually want at 2am when a ticket is in a state nobody expected.
Planning reads. Building writes. Never the reverse.
Two different jobs call Claude, and they are deliberately not symmetrical.
Plan generation clones the project's repo and runs the model with the tool set Read,Grep,Glob — no Edit, no Write, no Bash. It is read-only by construction rather than by instruction, so the planning stage cannot modify a repository even if the prompt goes sideways. Grounding it in real code changes what it asks for, too: the repo-aware system prompt tells it not to ask a developer anything it could have answered by reading a file.
The model answers through tool use, and only two tools exist: submit_plan, or request_clarification with the smallest set of questions that would unblock it. Those questions become a form on the ticket, the answers get fed back, and the loop is capped at three rounds — a model that cannot plan after three rounds of Q&A is telling you the bug report is the problem.
Code generation is the one that writes, and it is fenced in differently: an ephemeral shallow clone in a temp directory, a throwaway branch, a subprocess with a hard timeout that gets its process group killed on expiry, and a push of exactly one branch. If Claude produces no diff, that is a recorded failure with the output tail attached, not a silently empty PR.
Three layers of not filing the same bug twice
Dispatch also ingests Honeybadger faults, and error trackers are firehoses. Deduplication happens three times over: an exact notice replay is dropped at the door, a recurring fault bumps an occurrence count on the ticket that already exists, and anything that survives both goes to a semantic check.
That last one is a two-stage query, because the cheap thing should run first. A pg_trgm similarity search over the last sixty days — GIN-indexed, and it never leaves Postgres — narrows the field to twenty candidates. Only then does cosine similarity over Voyage embeddings rerank them. When there are no embeddings, because a customer never set a Voyage key, the trigram scores stand on their own and the panel still works. The interface is written so that pgvector, when it lands, replaces the rerank step and nothing above it notices.
Spending someone else's money carefully
An AI feature billed by the token is a product where a double-clicked button costs real money. Three things guard it:
- Postgres advisory locks per stage per ticket.
pg_try_advisory_lockkeyed on (stage, ticket id) means the second click finds the lock held and returns instead of queueing a second identical Claude run. - Metering that counts what actually happened. Every call records a
UsageRecordper kind per model, and the CLI runs reporttotal_cost_usdback from the subprocess — so code generation, usually the most expensive stage, does not show up on the cost dashboard as $0. - Caps with warnings before the wall. A per-tier monthly cap, an in-app banner and email at 80% and again at 100%, and the tripwire is idempotent per band per month so nobody gets the same warning six times.
Tenancy, and the boring parts done properly
The tenant comes from the subdomain, gets resolved into Current.account, and every tenant-scoped model carries a default_scope from it — a cross-tenant query has to say unscoped out loud. Controllers re-scope on load anyway, belt and suspenders. Account roles govern the account; per-project memberships govern each project; owners are implicit admins everywhere; Pundit policies cover every controller surface and the spec suite runs each policy against every role.
Around that sits the unglamorous half of a B2B product: TOTP two-factor with recovery codes, Google and GitHub OAuth that appear only when their keys are set, per-tenant SAML, a session list you can revoke a device from, GDPR export and purge (which pseudonymizes rather than deletes, so the audit trail survives, and refuses to remove the last owner), an owner-only audit log with tier-aware nightly retention, signed outbound webhooks, and a Swagger spec generated from the request specs rather than maintained by hand.
Every secret column — GitHub PATs, webhook signing secrets, Slack signing secrets, the SAML certificate — is encrypted at rest, signature verification is timing-safe, and wildcard CORS origins are rejected at save time rather than at request time.
Reporters get told what happened
The failure mode of every bug tracker is silence: you report something and never hear about it again. Dispatch emails the reporter on every meaningful transition, with signed-GID unsubscribe links, and reply-to-reporter comments are explicitly a different thing from internal ones — the visibility is a property of the comment, so an internal note cannot leak by being in the wrong list. Developers get a Monday-morning digest instead.
Stack and infrastructure
| Framework | Rails 8.1 on Ruby 3.4, Hotwire over importmap, Tailwind, ViewComponent, Propshaft |
|---|---|
| Data | PostgreSQL with pg_trgm; Solid Queue, Solid Cache, and Solid Cable — no Redis |
| AI | ruby-anthropic tool-use for planning and triage, the Claude Code CLI as a subprocess for code generation, Voyage embeddings for similarity |
| Auth | Devise with TOTP 2FA, OAuth and per-tenant SAML; Pundit policies at both account and project scope |
| Integrations | Octokit for PRs and merge webhooks, Slack Block Kit and slash commands, Honeybadger ingest, Stripe via Pay, signed outbound webhooks |
| Client SDK | dispatch-rails, an in-repo gem: one dispatch_widget_tag in a layout captures URL, user agent, viewport, and referrer |
| Hardening | Rack::Attack per IP and per token, Idempotency-Key on ingest, a metadata scrubber that strips secrets before storage, Trivy and RuboCop on pre-push |
| Interface | A C2/CRT terminal aesthetic — VT323, scanlines, neon glow — with Turbo Stream refreshes so the inbox updates itself |
| Hosting | Kamal onto a Terraform-provisioned AWS stack: ALB with ACM, two EC2 web boxes, RDS in private subnets, S3, KMS, WAF, and no NAT gateway — about $100/month |