Chapter 10 of 12 · Support and tickets

Ledger turns requests into tickets

Customers write to Sam. Sam writes to the CRM. Ledger reads the inbox every 30 minutes, classifies each request, drafts the reply and files a Linear issue when the request needs product work. Nothing goes back to a customer until Priya taps Send.

Agent LedgerModel Claude SonnetYou need Chapter 8 (Linear) done and the CRM demo open
The system

One inbox, one agent, one human tap

Intake in the CRM's Tickets module. Ledger triages on a schedule. Replies wait for Priya. Bugs and features become Linear issues in Idea. Delivery and quality go to Tom.

The whole point of Ledger is that no request is lost and no reply goes out unread. Those two promises pull in opposite directions. A person who never loses a request is slow. A person who answers fast makes mistakes. Ledger splits the job. It does the never-lose part on a timer, and it leaves the answer-fast part as a one-tap decision for Priya.

Open the Tickets module in the demo. Every ticket there went through the run on this page.

The agent file

Classify before you react

Copy this into .claude/agents/ledger.md. The five numbered steps are the whole procedure. The last section is the line Bellows reads.

---
name: ledger
description: >-
  Ledger, support and tickets. Use to triage new customer requests from the
  CRM ticket inbox: classify each one, set priority, link it to the account,
  draft a reply, and create a Linear issue in state Idea for anything that
  needs product work. Runs on a schedule via Bellows.
tools: Read, Grep, Glob, Bash, mcp__linear__*, mcp__notion__*
model: sonnet
---

You are Ledger. You never lose a request. You classify before you react, and
you draft the reply before anyone asks for it. You do not send anything.

## For each new ticket

1. **Classify**: `bug`, `how-do-i`, `feature`, `billing`, `delivery`, `quality`
   (roast or freshness complaints), `other`. One category.
2. **Priority**: `P1` (customer cannot order or is losing product), `P2`
   (blocked on something with a workaround), `P3` (everything else).
3. **Link** the ticket to the account and contact in the CRM. If the sender
   is unknown, say so; do not guess.
4. **Draft a reply** in Beacon's voice: acknowledge, say what happens next
   and when, sign as Sam. Mark it `Ready for Priya`. Refunds, credits and
   anything about pricing are always a gate.
5. **Route**: `bug` and `feature` become a Linear issue in state `Idea` with
   the ticket text, the account and the category as the first comment.
   `how-do-i` becomes a wiki FAQ candidate for Quill if it is the second
   time you have seen it. `delivery` and `quality` go to Tom's queue in the
   CRM, not to Linear.

## Weekly

Post a summary on the wiki page `Ticket pulse`: tickets by category, median
time to first reply, the three requests you saw most, and one sentence on
what Anvil should look at.

## Output that Bellows parses

One line per ticket: `TKT-<id> · <category> · <priority> · <account> · linear:<ALD-id or none> · reply:ready`.

Where each category goes

CategoryReplyWhere the work goes
bugDrafted, Ready for PriyaLinear issue in Idea, Request template
featureDrafted, Ready for PriyaLinear issue in Idea, Request template
how-do-iDrafted with the answer, Ready for PriyaSecond time seen: FAQ candidate for Quill
billingDrafted, no numbers promisedGate. Priya decides the credit
deliveryDrafted, Ready for PriyaTom's queue in the CRM, not Linear
qualityDrafted, Ready for PriyaTom's queue in the CRM, not Linear
otherDrafted, Ready for PriyaPriya decides
The intake shape

Six fields. Ledger does not care where they came from.

Here are the four tickets from Alderline's first week live, in the shape Ledger reads. In production these arrive one or two at a time. They are shown together so you can see the four kinds side by side.

[
  {
    "id": "TKT-118",
    "account": "Riverbend Café",
    "contact": "Maya Chen",
    "channel": "sam",
    "receivedAt": "2026-09-09T09:14:00-07:00",
    "text": "Can Sam leave a note for the driver about the back door? Luis keeps going to the front and we are not open until 7. Something on our account he sees every week."
  },
  {
    "id": "TKT-121",
    "account": "Northline Bakery",
    "contact": "Dev Patel",
    "channel": "email",
    "receivedAt": "2026-09-10T07:52:00-07:00",
    "text": "Last two bags of Bridgetown tasted flat. Roast date on the bag says 28 Aug. Can someone check the batch?"
  },
  {
    "id": "TKT-122",
    "account": "Hollow Oak Grocery",
    "contact": "Ruth Adler",
    "channel": "email",
    "receivedAt": "2026-09-10T11:30:00-07:00",
    "text": "Invoice 2261 shows 12 bags, we got 10. Please fix before we pay."
  },
  {
    "id": "TKT-125",
    "account": "Sixth Street Espresso",
    "contact": "Jonah Reyes",
    "channel": "form",
    "receivedAt": "2026-09-11T08:05:00-07:00",
    "text": "How do I change our Tuesday order? We want two more Morning Route and one less Slow River from next week."
  }
]

channel is where the request came from: sam when Sam logged a call or a visit, email when a customer wrote to the support address, form when they used the request form. account is the name as Sam typed it. Ledger matches it to the account record and says so when it cannot. text is verbatim. Ledger never rewrites what the customer said.

Worked run

Four tickets in, four lines out

Bellows runs Ledger every 30 minutes with this kickoff. You can run it yourself with the script below.

Use the ledger agent.

New tickets are in tickets/new.json. For each one: classify (bug, how-do-i,
feature, billing, delivery, quality, other), set priority (P1, P2, P3), link
to the account and contact in the CRM, draft a reply in Beacon's voice signed
as Sam and mark it Ready for Priya, and create a Linear issue in state Idea
for every bug or feature with the ticket text, account and category as the
first comment. Refunds, credits and pricing are always a gate: draft, mark,
stop.

Report one line per ticket in the format your agent file specifies.
{{EXTRA}}

The script

This is what a scheduled Ledger run is. Read the tickets file. Ask Claude Code, headless, to be Ledger and return one JSON object per ticket. Create a Linear issue for every bug or feature. Write the triaged file the CRM imports. Append one telemetry line. Forty-five lines, no framework.

#!/usr/bin/env node
// kit/scripts/ledger.mjs — Ledger's intake run.
//
// Reads new tickets from the CRM's ticket inbox (an export file or the CRM
// API), asks Ledger (Claude Code, headless) to classify, prioritise, link and
// draft a reply for each, then creates a Linear issue in state Idea for every
// bug or feature. Runs every 30 minutes from Bellows or its own schedule. It
// never sends the reply; that is Priya's tap in the CRM.
//
//   node kit/scripts/ledger.mjs tickets/new.json
//
// Env: LINEAR_API_KEY, LINEAR_TEAM

import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
import { spawnSync } from 'node:child_process';

const [,, src = 'tickets/new.json'] = process.argv;
const tickets = JSON.parse(readFileSync(src, 'utf8'));
if (!tickets.length) { console.log('no new tickets'); process.exit(0); }

const prompt = `You are Ledger (see .claude/agents/ledger.md). Triage these tickets. For each one return one JSON object per line with keys: id, category, priority, account, contact, reply, needsLinear (true for bug or feature), linearTitle, linearBody. Nothing else.\n\n${JSON.stringify(tickets, null, 2)}`;
const res = spawnSync('claude', ['-p', prompt, '--output-format', 'text'], { encoding: 'utf8', env: { ...process.env, AGENT_NAME: 'ledger' }, shell: process.platform === 'win32', timeout: 10 * 60 * 1000 });
const rows = (res.stdout || '').split('\n').map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);

async function linear(query, variables) {
  const r = await fetch('https://api.linear.app/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: process.env.LINEAR_API_KEY }, body: JSON.stringify({ query, variables }) });
  const j = await r.json(); if (j.errors) throw new Error(JSON.stringify(j.errors)); return j.data;
}
const teamId = (await linear(`query($k:String!){ teams(filter:{ key:{ eq:$k } }){ nodes{ id } } }`, { k: process.env.LINEAR_TEAM || 'ALD' })).teams.nodes[0].id;

const results = [];
for (const r of rows) {
  let linearId = 'none';
  if (r.needsLinear) {
    const d = await linear(`mutation($t:String!,$title:String!,$body:String!){ issueCreate(input:{ teamId:$t, title:$title, description:$body }){ issue{ identifier } } }`, { t: teamId, title: `[${r.category}] ${r.linearTitle}`, body: `${r.linearBody}\n\n---\nFrom ticket ${r.id} · ${r.account} · ${r.contact}\nFiled by Ledger.` });
    linearId = d.issueCreate.issue.identifier;
  }
  results.push({ ...r, linear: linearId, replyStatus: 'ready' });
  console.log(`${r.id} · ${r.category} · ${r.priority} · ${r.account} · linear:${linearId} · reply:ready`);
}
mkdirSync('tickets/triaged', { recursive: true });
writeFileSync(`tickets/triaged/${new Date().toISOString().slice(0, 10)}.json`, JSON.stringify(results, null, 2));
mkdirSync('telemetry', { recursive: true });
appendFileSync('telemetry/events.jsonl', JSON.stringify({ ts: new Date().toISOString(), agent: 'ledger', event: 'end', minutes: 2, tickets: results.length, issues: results.filter(x => x.linear !== 'none').length }) + '\n');

Check --output-format against the current Claude Code docs. The pattern is claude -p with the prompt as the argument and the output captured.

The run

node kit/scripts/ledger.mjs tickets/new.json
TKT-118 · feature · P3 · Riverbend Café · linear:ALD-42 · reply:ready
TKT-121 · quality · P2 · Northline Bakery · linear:none · reply:ready
TKT-122 · billing · P2 · Hollow Oak Grocery · linear:none · reply:ready
TKT-125 · how-do-i · P3 · Sixth Street Espresso · linear:none · reply:ready
# TKT-121 · quality → routed to Tom's queue in the CRM, not Linear
# TKT-122 · billing → GATE: billing. Reply drafted, no credit promised. Waits for Priya.
tail -n 1 telemetry/events.jsonl
{"ts":"2026-09-11T15:36:02.418Z","agent":"ledger","event":"end","minutes":2,"tickets":4,"issues":1}

Four lines, one per ticket, in the format the agent file specifies. The # lines are notes on the run, not script output. The routing for TKT-121 and the gate on TKT-122 are in tickets/triaged/2026-09-11.json, which the CRM reads back. One Linear issue was created: ALD-42. Here is what Sam sees in the CRM.

alderline-crm.vercel.app/tickets/TKT-118
TKT-118featureP3Riverbend Café · acc_014Linear · ALD-42
From Maya Chen, ownerChannel Sam, phoneReceived Wed 9 Sep 2026, 9:14 amRoute Tuesday East · Luis Ortega
Can Sam leave a note for the driver about the back door? Luis keeps going to the front and we are not open until 7. Something on our account he sees every week.
Ledger · triage · 9:31 am classified in 17 min

Feature request. Not blocked: Sam has told Luis directly for this week. Linked to account acc_014 and contact Maya Chen. Linear issue ALD-42 created in Idea with the Request template.

Reply · drafted by Ledger in Beacon's voice Ready for Priya

Hi Maya,

Thanks for flagging this. You are right that it should live on your account, not in Luis's memory. We are adding a note to your account that Luis sees on his route sheet every week, so "back door after 7am" travels with every delivery.

I expect it live within two weeks and I will tell you the week it is. Until then I have told Luis myself.

Sam

EditSend (Priya)Sent replies are logged to Activity on the account.

And here is the issue Ledger created. The title carries the category in brackets. The body is the Request template from kit/linear/workflow.md, filled. Anvil will rename it to "Route notes on an account" when it scopes.

linear.app/alderline/issue/ALD-42
IdeaALD-42 · [feature] Route note for the driver on an account
Team Alderline CRMLabel featureEstimateCreated by Alderline Crew (Ledger) · 9 Sep 2026, 9:31 am
From the customer

Can Sam leave a note for the driver about the back door? Luis keeps going to the front and we are not open until 7. Something on our account he sees every week.

Account

Riverbend Café · Maya Chen · ticket TKT-118

Category

feature


From ticket TKT-118 · Riverbend Café · Maya Chen
Filed by Ledger.

What happens next

Bellows sees an issue in Idea on its next tick and runs Anvil. Chapter 3 picks the thread up there. Chapter 12 follows it all the way to Deployed.

The parameters

Service levels Ledger keeps for you

These are Alderline's numbers. Write yours into the agent file and the schedule. Ledger keeps the drafting side. Priya keeps the sending side.

ParameterAlderline's valueWho keeps it
First reply draftedWithin 30 minutes of intakeLedger's schedule: Bellows runs ledger.mjs every 30 minutes
P1 · cannot order, or losing productPriya pinged immediatelyLedger reports P1; the run posts to GATE_WEBHOOK. The kit script does not do this yet. Add a fetch when priority is P1.
P2 · blocked, has a workaroundReply sent the same business dayPriya, from the Waiting on Priya view
P3 · everything elseReply sent within two business daysPriya
Refunds, credits, pricingAlways a gate: draft, mark, stopLedger's file and gates.json
Ticket pulseMonday 8:00 am Pacific, wiki page Ticket pulseLedger, on a weekly schedule

The weekly Ticket pulse

Every Monday Ledger posts one page. Priya reads it in two minutes. Anvil reads the last line. This is the page for Alderline's first week live.

notion.so/alderline/Ticket-pulse
Ticket pulse · week of 7 Sep 2026
Posted Mon 14 Sep, 8:00 am · LedgerTickets 13Open 2 (both Tom's)
By category
5 how-do-i3 feature2 delivery2 quality1 billing0 bug
Median time to first reply

Drafted: 19 minutes. Sent by Priya: 3 hours 10 minutes. Slowest send was TKT-122 (billing), 26 hours, waiting on Tom's delivery record.

The three requests seen most
  1. Change our weekly order without calling Sam (3 tickets: Sixth Street Espresso, Marigold Coffee, Kenton Coffee Co).
  2. A note for the driver on the account (2: Riverbend Café, Cedar Loop Café). Now ALD-42.
  3. See the invoice before it is sent (2: Hollow Oak Grocery, Tidewater Market).
For Anvil

Three how-do-i tickets this week were the same question. A self-serve change to the standing order on the account page is worth scoping.

Your product

Build the intake in your own product

Ledger needs a JSON file or an endpoint with the six fields. How the tickets get there is up to you. Three ways, easiest first.

  1. A form in the app

    The Tickets module has a New request form Sam fills in during a call or a visit: account, contact, channel, what they said. It writes a row with the six fields. An export endpoint writes tickets/new.json for Ledger, or Ledger reads the API directly. This is what Alderline built as ALD-6 and it is the path most tickets take.

  2. An email forward

    Point YOUR_SUPPORT_ADDRESS at a mailbox a script reads. A short script pulls unread mail over IMAP or the Gmail API and writes the same JSON shape. The sender's address becomes contact. Ledger matches it to an account and says "unknown sender" when it cannot, rather than guessing. Ask Ember to build the script; it is a two-point slice.

  3. A shared inbox export

    If you already live in Front, a HubSpot inbox or a Gmail label, export to CSV once a day and convert it to the JSON shape with one node script. Slower and fine for a start. Move to the form when Sam is tired of forwarding.

Keep the shape

Whatever the path, keep the six fields: id, account, contact, channel, receivedAt, text. Ledger only needs those. Add fields to your ticket table if you want, but do not make Ledger read them. The less it needs, the less breaks.

Founder Q&A

Does Ledger reply to customers?

No. It drafts. The reply sits in the CRM marked Ready for Priya with a button that says Send (Priya). Priya reads it, edits a word if she wants to, and sends. On a busy week she can name Sam as the sender for how-do-i replies. That is a change to gates.json and a decision Quill records, not something Ledger decides for itself.

What about phone calls?

Sam logs them as tickets. The New request form takes thirty seconds: account, contact, channel "phone", what they said in their words. Ledger treats it like any other ticket. Nothing is lost because it was said out loud. TKT-118 was a phone call.

How do refunds work?

Always a gate. Ledger drafts a reply that acknowledges the problem and says what happens next. It never promises money. Priya decides the credit. Tom confirms the delivery record. The reply goes out with the number in it once both have happened. TKT-122 is the example: 12 bags invoiced, 10 delivered. The draft said "we are checking the delivery record and will correct the invoice before it is due". The credit line was Priya's.

What if Ledger misclassifies?

Priya or Sam reclassifies in the CRM with one click. The change is logged on the ticket. If the same mistake repeats, that is a learning-log entry: the agent file gets a sentence and Quill records why. In Alderline's first week two quality complaints were filed as delivery. The fix was the parenthesis you can see in the agent file above: "quality (roast or freshness complaints)". It has not happened since.

Chapter 10

What you have now

A ticketing system with no ticketing vendor. Intake in your own product, in six fields. An agent that reads the inbox every 30 minutes and never loses a request. A reply drafted in your voice before you knew there was a question, waiting for your tap. A Linear issue in Idea for everything that needs building, with the customer's words as the first comment. A Monday page that tells you where the product is thin. And a gate on every dollar.

Next: Bellows, the timer that runs Ledger and everyone else.