Recursivity
All projects
Private Featured

Dispatch

AI-native bug ticketing for the app you have not shipped yet

LanguageRuby on Rails 8.1
ModelMulti-tenant SaaS
HostingKamal on AWS (Terraform)
StatusPrivate
  • The inbox: every project's reports in one queue, filtered by state, severity, and SLA.
    The inbox: every project's reports in one queue, filtered by state, severity, and SLA.
  • A fix plan Claude wrote after reading the repository — real files, real call sites.
    A fix plan Claude wrote after reading the repository — real files, real call sites.
  • Too thin to plan from. Three questions, each with why it blocks the work.
    Too thin to plan from. Three questions, each with why it blocks the work.
  • The pull request it opened, its branch and SHA, and an SLA already breached.
    The pull request it opened, its branch and SHA, and an SLA already breached.
  • Tracked exceptions, deduplicated by fingerprint and counted by affected user.
    Tracked exceptions, deduplicated by fingerprint and counted by affected user.
  • Per-project setup: API token, inbound email address, and the widget snippet.
    Per-project setup: API token, inbound email address, and the widget snippet.
  • What the AI actually cost, per stage and per model, against the plan's cap.
    What the AI actually cost, per stage and per model, against the plan's cap.
  • The audit log — owner-only, and pruned nightly on a tier-aware window.
    The audit log — owner-only, and pruned nightly on a tier-aware window.

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:

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

FrameworkRails 8.1 on Ruby 3.4, Hotwire over importmap, Tailwind, ViewComponent, Propshaft
DataPostgreSQL with pg_trgm; Solid Queue, Solid Cache, and Solid Cable — no Redis
AIruby-anthropic tool-use for planning and triage, the Claude Code CLI as a subprocess for code generation, Voyage embeddings for similarity
AuthDevise with TOTP 2FA, OAuth and per-tenant SAML; Pundit policies at both account and project scope
IntegrationsOctokit for PRs and merge webhooks, Slack Block Kit and slash commands, Honeybadger ingest, Stripe via Pay, signed outbound webhooks
Client SDKdispatch-rails, an in-repo gem: one dispatch_widget_tag in a layout captures URL, user agent, viewport, and referrer
HardeningRack::Attack per IP and per token, Idempotency-Key on ingest, a metadata scrubber that strips secrets before storage, Trivy and RuboCop on pre-push
InterfaceA C2/CRT terminal aesthetic — VT323, scanlines, neon glow — with Turbo Stream refreshes so the inbox updates itself
HostingKamal 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