How to Build 8 GTM Agents With Claude: Skills, Slack, Railway
The 8 GTM agents we run and how each one is built: the Claude skill, the script, the Slack approval card, the database that ties them together, and the Railway setup.

On this page
- Is a Claude skill an agent?
- The concept: workflows with one smart step
- The stack
- How to write a Claude skill that works
- The 8 GTM agents and how each is built
- The Slack layer: approve, edit, skip
- How the agents connect
- Running it on Railway
- Picking models and keeping cost sane
- Testing: how you know an agent works
- What breaks: the list
- Where to start
My team built 300+ agents in 2025 and 2026 [O1]. We still run 24 of them, and eight are the ones every GTM team should build first: signals, routing, content, analytics, social intent, CRM hygiene, SEO and GEO, and replies. This page is how each one is built, the parts we kept after rebuilding most of them twice, and what breaks.
Every agent we run has the same four parts:
- A Claude skill for the judgment (score this signal, draft this reply, write this page).
- A plain script for everything deterministic (pull the data, call the API, write the row).
- One shared Postgres database that every agent reads and writes, so they never talk to each other directly.
- A Slack card with buttons wherever the agent would touch a prospect. Every one-to-one message waits for a person's click, and a campaign only starts sending after a person approves the campaign.
They run on Railway: one always-on service for webhooks and Slack, and a handful of cron jobs.
Is a Claude skill an agent?
No. A skill is the instructions: a folder with a SKILL.md file that says what to look at, the rules, and the shape of the output. Claude loads it when the job comes up. It does not run on a schedule, it does not remember what it saw yesterday, and it does not stop a bad message from going out. On its own it is a playbook.
An agent that runs every night, watches your accounts across several databases and posts what it found needs five pieces:
- A schedule. A cron job starts the run at 6am, or every hour.
- Code for the dumb work. Pull from the data providers, dedupe, write the rows. Claude doing this is slower, costs more and is less reliable than a script.
- Memory. A database of what it already saw, what it already sent and what got approved. Without it every run starts from zero and contacts the same people twice.
- The thinking step. Claude, through the API or the Agent SDK, scores the signal, writes the message or classifies the reply. This is where the skill plugs in. The Agent SDK loads the same skill files from
.claude/skills/that you test by hand in Claude Code, so the instructions you checked are the ones that run overnight. - A gate. A Slack card with Approve, Edit and Skip, plus logs and an alert when a run fails.
The skill is maybe a fifth of the work. The rest is ordinary engineering, and it is the part most "agents" skip, which is why they break in the second week. The rest of this page builds all five pieces for each agent.
The concept: workflows with one smart step
Anthropic draws a line between workflows ("LLMs and tools are orchestrated through predefined code paths") and agents (the model "dynamically directs its own process") and tells you to start with "the simplest solution possible". Almost every GTM job is a workflow with one or two steps that need judgment. A signal comes in, a script enriches it, Claude decides the tier and writes the message, a person approves, a script sends.
Building it that way matters for two reasons. Cost: an agent loop uses about 4x the tokens of a chat, and a multi-agent system about 15x. And errors: "Agents are stateful and errors compound". A pipeline where the model only makes the decisions a model is good at is cheaper, easier to test, and fails in one place at a time.
So when I say "agent" on this page I mean: a scheduled or event-triggered job, with a Claude skill at the decision point, writing to a shared database, with a human gate before anything external.
The stack
| Layer | What we use | Why |
|---|---|---|
| Brain | Claude (skills + Agent SDK or plain API calls) | Skills keep each job's instructions in one file the model loads only when needed |
| Code | Python scripts in one repo | Deterministic steps stay out of the prompt |
| Database | Postgres (Supabase) | One ledger every agent shares |
| Data | AI Ark, BlitzAPI, Clay, Prospeo, Exa, Apify | Waterfall: cheapest first, stop at the first valid result |
| Send | Instantly (email), HeyReach (LinkedIn) | Campaign tools stay the only senders |
| CRM | Attio | Webhooks and upserts by a matching attribute |
| Calls | Fireflies, Cal.com | Transcripts and real availability |
| Human gate | Slack (Bolt) | Approve, Edit, Skip on every one-to-one message |
| Hosting | Railway | One web service plus cron jobs |
The repo looks like this:
gtm-agents/
.claude/skills/
signal-scorer/SKILL.md
message-writer/SKILL.md
reply-drafter/SKILL.md
post-drafter/SKILL.md
geo-page-writer/SKILL.md
...
icp.yml # the ICP, one file, read by every agent
voice/ # approved examples, the voice the drafts copy
jobs/ # one entry point per cron job
signals_daily.py
intent_hourly.py
analytics_weekly.py
geo_weekly.py
app/ # the always-on service: webhooks + Slack
main.py
slack_cards.py
db/schema.sqlHow to write a Claude skill that works
A skill is "a directory containing a SKILL.md file" plus any scripts and reference files it needs. In Claude Code it lives in .claude/skills/<name>/SKILL.md. The part people get wrong is the frontmatter, because it decides whether the skill ever fires.
Claude sees only the name and description of every skill at startup (about 100 tokens each). The body loads when the description matches the task, and bundled files load only when the body points to them. So:
- The description is the trigger. Say what the skill does and when to use it, in the third person. Max 1,024 characters.
- The name is lowercase letters, numbers and hyphens, max 64 characters, and it cannot contain "claude" or "anthropic".
- Keep the body under 500 lines and push detail into files one level down.
- Put deterministic work in a script the skill calls. Only the script's output enters context, which keeps the window small.
- Write three test cases before you write long instructions. More on testing below.
- Give it few, sharp tools. "More tools don't always lead to better outcomes," and a tool that just mirrors an API endpoint is a common mistake. One
enrich_accountscript beats six raw provider calls.
One rule we added on top: a skill never holds the ICP, the voice, or a price. It reads them from icp.yml and voice/ every time. Copies drift.
The 8 GTM agents and how each is built
1. Signals: watch your tier 1 accounts every day
What it does. Every morning it checks your tier 1 accounts for the events you defined: a new VP of Sales, a funding round, hiring for the role you replace, a champion who changed jobs, a pricing page visit. It writes each hit to a signals table.
Which signals are strongest. Octave rates pricing visits and demo requests strongest, champion moves next, then trigger events like funding and executive hires, then third-party intent, and tells you to measure which ones actually turn into pipeline. Speed matters as much as the list: under 4 hours from detection to outreach for the strong ones, and by day 14 you are competing with every other vendor who noticed the same champion move. Unify warns against a single freshness window; set an expiry per signal type.
How it is built. A cron job pulls each source, writes raw hits with a unique (source, source_event_id) so reruns never double count, and calls one skill per new hit:
---
name: signal-scorer
description: Scores a new account signal against the ICP in icp.yml and returns tier 1, 2 or 3 with a one-line reason and an expiry date. Use when a row lands in the signals table or when asked to triage signals.
---
# Signal scorer
## Inputs
- The signal row: kind, account domain, payload, detected_at.
- icp.yml. Read it every run. Never score from memory.
## Steps
1. Run `python scripts/enrich_account.py <domain>`. It returns headcount,
industry, HQ country, funding and whether the domain has a LinkedIn page.
2. Hard gates. Fail any one and return tier 3 with the gate as the reason:
- HQ country not in icp.yml `countries`
- the company sells what we sell (read their homepage, do not guess)
- no LinkedIn company page
3. Score fit 0 to 100 from icp.yml `fit_weights`.
4. Tier: fit >= 70 and signal strength high = 1. Fit >= 50 = 2. Else 3.
5. Expiry: icp.yml `signal_expiry_days[kind]` from detected_at.
## Output
JSON only: {"tier": 1, "reason": "...", "expires_at": "YYYY-MM-DD"}
## Gotchas
- A competitor looks like perfect ICP to a keyword match. Step 2b is a judgment,
so read the site.
- Funding news is often weeks old by the time a database has it. Use the
announcement date, not the ingest date.Clay scores its own accounts on three axes: fit, a rolling 90 days of engagement, and potential contract value. Steal that. Fit alone tiers the list once; engagement is what moves an account between tiers week to week.
Watch out for. Alert fatigue. Octave's guidance is to start with 8 to 12 signals, because past 30 most of it is noise and reps who get about 30 alerts a day stop reading them. And your waterfall's catch-all handling: Clay counts catch-all emails as valid by default unless you switch on "Safe to Send" only.
2. Routing: the exact message, waiting in Slack
What it does. Takes every scored signal and decides what happens. Tier 1 goes to Slack as a card with the drafted message, written from that specific signal, and three buttons: Approve, Edit, Skip. Tier 2 goes into the right email campaign. Tier 3 is logged and nothing else.
This is the split Octave describes: high-strength signals to Slack for manual outreach, medium to automated sequences, low to nurture. LangChain built the same gate into its GTM agent: "Nothing is sent without an explicit rep review".
How it is built. A message-writer skill reads the signal, the person, voice/ and one rule we hold everywhere: the message must mention the signal in the first line or it does not get sent. The router then:
- Tier 1: inserts a row in
draftswith statuspendingand posts the card (code in the Slack section below). - Tier 2: calls Instantly's
POST /api/v2/leads/addwith up to 1,000 leads per call.
Watch out for. There is no "put this lead at the top" field in that call. Instantly sends follow-ups before new leads by default, and the switch that flips it ("Prioritize new leads") only exists on the higher plans. If you want signal leads to go out first, route them into a campaign that has the switch on. On LinkedIn, adding leads through HeyReach's API reactivates a paused or finished campaign, so check the campaign state before you push.
3. Content engine: what performs for your ICP, drafted from your calls
What it does. Pulls the posts that do well with your buyers, drafts in your voice from your own sales calls and builds, and logs what you posted and how it did, so the next draft is better.
I believe in writing your own posts. But a post that ignores what your audience reads gets no reach, and LinkedIn ranks on dwell time and the predicted chance someone skips you. So the agent does the research and the first draft, and you put yourself in it.
How it is built.
- A weekly job runs an Apify actor that returns post text, date and engagement counts for the creators your buyers follow.
- A
post-drafterskill gets three to five of your best performing posts as examples, which is the range Anthropic recommends for steering tone, plus one transcript excerpt as the substance. - After you post, a capture step stores what you published next to what the agent drafted. Your edits are the training data (see agent 4).
Watch out for. LinkedIn's user agreement bans bots and scraping, and a third-party actor does not change that; the risk sits with you. If you use one anyway, pull posts and engagement counts rather than people, keep volume low, and never run it through your own logged-in account. And never let the agent post.
4. GTM analytics: the agent that learns
What it does. Reads every campaign's numbers, every pipeline stage, and every edit a person made to an AI draft. Then it tells you which offer, persona and account type is working, and it writes those findings back into the files the other agents read.
The metrics. Reply rate alone lies. Instantly's own 2026 data puts the average reply rate at 3.43 percent and the top tenth at 10.7 percent or more, but those count all replies, including follow-up responses, not just positive ones. Track positive replies per 1,000 delivered, meetings held (not booked) per 1,000, cost per meeting held, and edit rate on drafts. Skip open rates.
The learning loop. Every draft is stored next to the version a person actually sent. Once a week, a skill reads the diffs and turns them into short written rules ("he never opens with a compliment", "under 90 words on a first touch") that get appended to voice/rules.md. This is close to what the CIPHER paper does: infer the preference from the edit, then retrieve the most similar past preferences for the next draft. On an email writing task it cut edit cost by 73 percent against no learning, though with a simulated user.
Watch out for. Don't add sourced and influenced pipeline together, and don't change attribution models every quarter. Pick one and leave it.
5. Social intent: viewers, likers, commenters, visitors
What it does. Every profile viewer, every person who likes or comments on your posts, and every identified website visitor gets checked against the ICP. The ones who pass land in Slack with a ready message.
How it is built. Three feeds into one table: the people who engage with your posts (pulled on a schedule), profile viewers (exported by hand from your own analytics, since they only exist inside your session), and RB2B for site visits. Then the same signal-scorer skill from agent 1, with one extra hard gate.
The gate that matters most. The people who engage with GTM content are mostly people who sell GTM. When we audited one GTM creator's audience, 11 of 17 people who looked qualified sold what we sell [O4]. Understory gives the same advice: filter out peers, vendors and job seekers before you enrich, and act within a week.
Watch out for. RB2B identifies people only for US visitors; everyone else is company level at best. Never thank someone for liking a post. And on LinkedIn, keep invites low and spread out. HeyReach allows up to 40 a day and defaults to 25; we cap ours at 20, weekdays only, drip-spaced through the day [O3].
6. CRM hygiene: the work that piles up
What it does. Once a pipeline starts producing, you drown in follow-ups, proposals and nudges. Salesforce's survey of 5,500 sellers found non-selling work takes 70 percent of a rep's time. This agent does the admin: call prep before every meeting, notes into the CRM after, the follow-up drafted from the transcript, a nudge when a deal goes quiet.
How it is built.
- Fireflies sends a webhook when a call is transcribed. The payload has only the meeting ID, so the agent fetches the transcript.
- It matches the call to a CRM record by attendee email, then upserts in Attio with a matching attribute so it never creates duplicates.
- It writes four fields and nothing else: stage, next step, next step date, owner. Every write links back to the transcript or thread it came from.
- The follow-up draft goes to Slack like every other message.
Watch out for. Identity. Work email changes when people change jobs, so key people on their LinkedIn URL and use email as a matching signal. Attio rejects an upsert that matches more than one record, which is good: it surfaces your duplicates instead of hiding them.
7. SEO and GEO: what AI recommends for your buyers
What it does. Tracks what ChatGPT, Perplexity, Claude and Google recommend when your buyers ask your category's questions, then writes the pages and pitches that get you into those answers.
What the evidence says.
- The Princeton GEO study found that adding quotations (+41 percent) and statistics (+31 percent) raised a source's visibility in generative answers, while keyword stuffing hurt it (-8 percent). Lower-ranked pages gained the most. Those are relative gains on the authors' simulated engine; on live Perplexity the best method gained about 22 percent.
- Ahrefs looked at 75,000 brands and found branded web mentions correlate with AI Overview visibility at 0.664, against 0.218 for backlinks. That is correlation, not proof, but it is why half this agent's work happens on other people's sites.
- Google says AI-assisted content is fine unless its purpose is manipulating rankings, and that you need no special files or markup to appear in AI Overviews. Ahrefs checked 137,210 domains and found that 97 percent of the llms.txt files they publish got zero requests in a month. Don't spend time there.
How it is built.
- Weekly prompt run: 30 to 40 buyer questions sent to each engine through DataForSEO's LLM Responses API. Store who got named and which pages got cited.
- Gap list: every cited page that does not mention you becomes a pitch draft (lists, directories, podcasts). Pitches go to Slack. You send them.
- Page writer: a
geo-page-writerskill drafts the pages, with a hard rule that every number traces to a source:
---
name: geo-page-writer
description: Drafts a comparison or how-to page aimed at a buyer question tracked in prompts.csv, using only facts in the brief's evidence file. Use when the weekly prompt run flags a question where competitors are cited and we are not.
---
# GEO page writer
## Inputs
- The buyer question and the pages the engines cited for it (from the prompt run).
- evidence.md for this page. The only facts you may use.
## Rules
- Verdict in the first 100 words. Engines quote the top of a page.
- Every number and every quote carries [E#] that exists in evidence.md.
A sentence that needs a number we do not have gets rewritten without it.
- Name six or more real options on list pages, one honest line each.
- Add direct quotes and statistics with sources. They measurably raise
visibility in generative answers. Never stuff keywords.
- Visible updated date and author.
## Output
draft.md, then stop. A separate review checks every [E#] before a person approves.- Measurement: Search Console's API gives queries and clicks by page and date, but it does not split out AI Overview traffic, which is why the prompt run exists.
Watch out for. Publishing without a person reading it. The volume play is exactly what Google calls spam.
8. Reply agent: read the thread, draft the answer, book the call
What it does. For every campaign, it reads the whole thread when a reply comes in, classifies it, drafts the answer in your voice with real open times from your calendar, offers the entry offer when it fits, and posts it to Slack. You approve and it sends. When they pick a time, a short confirmation is drafted, and on your approval it books the call.
Why speed matters. The often-quoted study of 15,000+ inbound leads found the odds of qualifying a lead drop 21 times if you call at 30 minutes instead of 5. HBR's audit of 2,241 firms found an average response time of 42 hours. Those are inbound web leads worked by phone, not cold email replies, so don't quote "21x more likely to close". The direction holds anyway: a positive reply answered in minutes beats one answered tomorrow.
How it is built.
- Full thread first. Never draft from the last message alone. Count how many of our messages went unanswered before it.
- Classify into one of: positive, question, objection, not now, out of office, unsubscribe, wrong person. Unsubscribe gets suppressed immediately and never drafted. Objections go to a person.
- Slots. Call Cal.com's
GET /v2/slotswith the prospect's time zone. If you leavetimeZoneout, it defaults to UTC and you will offer times hours off. - Draft with the
reply-drafterskill:
---
name: reply-drafter
description: Drafts a reply to a positive or question reply from a cold campaign, using the full thread, the lead's record, two real calendar slots in the lead's time zone, and the voice in voice/. Use when a reply is classified positive or question.
---
# Reply drafter
## Before writing
- Read the whole thread. Note every claim we already made. Do not repeat or
contradict them.
- Read voice/rules.md and three examples from voice/approved/.
## The reply
- Answer their question first, in one or two sentences.
- One ask. If they asked for times, give the two slots exactly as provided,
in their time zone, with the zone name.
- If the entry offer fits what they said, offer it in one line.
- 45 to 75 words. No compliments. No "just following up".
## Never
- Never invent a time, a price, a customer name or a result.
- Never promise anything not in offers.md.
- If the thread is unclear, output NEEDS_HUMAN and one line on why.- Lint, then critic. A script checks the deterministic things (no placeholder left, our links only, the slots match what the API returned). Then a second Claude call that sees the writer's rules reviews the draft before it reaches Slack.
- Re-check the slot at send time. Slots go stale between drafting and approval.
- Book only after they pick a slot, with
POST /v2/bookings. Start time in UTC, the attendee's IANA time zone required, and any required booking question answered, which you only know by fetching the event type first.
Watch out for. Full autopilot. Even the vendors start you in review mode: Instantly's reply agent recommends human-in-the-loop for new users. TechCrunch, citing current and former employees, reported 11x customers complaining about hallucinations and correcting output by hand. A reply agent that invents a time or a claim costs you the meeting and the domain's reputation in one message.
The Slack layer: approve, edit, skip
Every outbound message in this system waits in Slack. Here is the approval handler in Bolt for Python, with the four rules that keep it safe:
import os
from slack_bolt import App
from db import db
from senders import send_draft
from slack_cards import result_card, edit_modal
app = App(token=os.environ["SLACK_BOT_TOKEN"],
signing_secret=os.environ["SLACK_SIGNING_SECRET"])
APPROVERS = set(os.environ["APPROVER_IDS"].split(","))
@app.action("approve_draft")
def approve(ack, body, client, respond):
ack() # rule 1: ack inside 3 seconds
user = body["user"]["id"]
draft_id = body["actions"][0]["value"]
if user not in APPROVERS: # rule 2: approver allowlist
respond(text="Only the owner of this book can approve.",
response_type="ephemeral", replace_original=False)
return
row = db.fetchone( # rule 3: claim it once
"update drafts set status='sending', approved_by=%s "
"where id=%s and status='pending' returning *", (user, draft_id))
if row is None:
return # double click or already handled
try: # one attempt, logged before and after
status = "sent" if send_draft(row).ok else "failed"
except Exception:
status = "unknown" # timeout: check the thread, never resend blind
db.execute("update drafts set status=%s where id=%s", (status, draft_id))
client.chat_update( # rule 4: send the full blocks
channel=body["channel"]["id"], ts=body["message"]["ts"],
text=status.title(), blocks=result_card(row, user, status))
@app.action("edit_draft")
def edit(ack, body, client):
ack()
client.views_open(trigger_id=body["trigger_id"], # open it first
view=edit_modal(body["actions"][0]["value"]))Why each rule exists:
- Ack first. Slack needs a 200 within 3 seconds or the user sees an error. Do the Claude call or the send after
ack(). On an always-on server this is simple; on serverless you need Bolt's lazy listeners. - An allowlist of approvers. Slack has no built-in "who can click this". Check the user ID yourself, per book if different people own different campaigns.
- Claim the row with a conditional update.
where status='pending'means a double click, a retry or a second person can never send twice. - Send the full blocks on `chat.update`. Sending only
textwipes the card's blocks.
Two more you will hit:
- Edit opens a modal. The
trigger_idthat opens it expires in 3 seconds and works once, so open the modal before any slow work. Carry the draft ID inprivate_metadata. On submit, save the edited text toapproved_bodyand run the same conditional claim as Approve. - Verify the signature on the raw body, and reject requests older than 5 minutes. Bolt does this for you; a hand-rolled webhook does not.
A send that fails cleanly is failed. A send that times out is unknown, and unknown means check the thread, never resend blind. We learned that one the hard way: a script run with a bare send flag once sent 14 replies nobody had approved [O2]. Now one module is the only code that can send, every send is logged before and after, and a test fails if any other file calls a send endpoint.
Talking back to the bot. Subscribe to app_mention so people can update the CRM from Slack ("followed up with Maya, next touch Friday"). Use app_mention over reading every channel message; it needs fewer scopes and less noise.
Rate limits. chat.postMessage allows about one message per second per channel. If you post a morning batch of 40 cards in a loop, handle the 429 and its Retry-After.
How the agents connect
The agents never call each other. They share one Postgres database, and each one reads the tables it needs and writes its own. It means any agent can be rebuilt without touching the others, and you can always answer "what happened to this lead".
create table people (
id bigint generated always as identity primary key,
linkedin_url text unique, -- normalize first: lowercase, no www, no trailing slash
email text,
company_domain text,
created_at timestamptz default now()
);
create unique index on people (lower(email)) where email is not null;
create table signals (
id bigint generated always as identity primary key,
account_domain text not null,
kind text not null,
source text not null,
source_event_id text not null,
tier int,
expires_at date,
detected_at timestamptz default now(),
unique (source, source_event_id)
);
create table drafts (
id uuid primary key default gen_random_uuid(),
agent text not null,
person_id bigint references people(id),
channel text not null,
body text not null,
approved_body text,
status text not null default 'pending'
check (status in ('pending','sending','sent','failed','unknown','skipped')),
slack_channel text,
slack_ts text,
approved_by text,
created_at timestamptz default now()
);
create table events (
id bigint generated always as identity primary key,
source text not null,
event_id text not null,
kind text not null,
person_id bigint references people(id),
payload jsonb,
at timestamptz default now(),
unique (source, event_id)
);How data flows through it:
- Signals writes
signals. Routing reads new tiered rows and writesdraftsor pushes to a campaign. - Social intent writes
signalstoo, with its ownsource. The router does not care where a signal came from. - Webhooks from Instantly, Attio, Cal.com and Fireflies all land in
eventswith a unique key, so a retried webhook is a no-op. Use the provider's delivery ID where one exists (Attio'sIdempotency-Key, Slack'sevent_id). Otherwise build one: Firefliesevent + meeting_id, Cal.comtrigger + booking uid + start time, Instantlyevent_type + email idor a hash of the payload. - Reply agent reads
eventsfor new replies, writesdrafts. - CRM hygiene reads
eventsfor calls and bookings, writes Attio. - Analytics reads everything and writes
voice/rules.mdand a weekly report.
The unique keys are not optional. Attio retries a webhook up to 10 times over about three days and sends an Idempotency-Key that stays the same across retries. Slack retries events up to three times. Cal.com signs its webhooks only if you set a secret, and Instantly documents no signing at all, so put a long random token in the webhook URL path and reject any request without it.
Do you need Temporal, Inngest or a queue? Not for eight agents. Postgres with unique keys and conditional status updates is enough. Reach for a durable workflow engine when you need multi-step waits ("send day 1, wait 3 days, check, send day 4") across thousands of leads.
Running it on Railway
The layout we use:
| Service | Type | What it runs |
|---|---|---|
| hub | Always-on web service | Slack interactivity, all webhooks, the /health route |
| signals-daily | Cron, 0 13 * * 1-5 | Agent 1, 13:00 UTC on weekdays (8 or 9am US Eastern) |
| intent-hourly | Cron, 15 * * * * | Agent 5 |
| analytics-weekly | Cron, 0 14 * * 1 | Agent 4 |
| geo-weekly | Cron, 0 15 * * 2 | Agent 7 |
Replies, CRM hygiene and routing run inside hub, triggered by webhooks and new rows.
What to know before you deploy:
- Cron runs in UTC, at least 5 minutes apart, and if the previous run is still going, the new one is skipped, not killed. So every cron job must exit, close its database connections, and put a hard timeout on every Claude call. One hung call blocks every run after it.
- Cron is not for bots or servers. The Slack app and webhook receiver belong in the always-on service.
- Don't schedule inside the web process. An in-process scheduler loses its jobs when the process restarts.
- Healthchecks run only at deploy. They are not monitoring. Write a run row at the end of every job and alert when one is missing.
- Replicas cannot use volumes. Keep state in Postgres, not on disk.
- Variables: put shared keys in shared variables and reference them per service with
${{ shared.KEY }}; seal the secrets. - Config files are changing. Railway's
railway.jsonconfig-as-code is deprecated, and legacy files keep working only until 2026-12-01. The replacement is infrastructure as code in.railway/railway.ts. If you set a service up today, set the cron schedule in the service settings and don't start a newrailway.json. - Supabase from Railway: use the session pooler if your host is IPv4 only, and the transaction pooler (port 6543) only if your driver does not use prepared statements.
- Webhook receivers: verify the signature, return 200, then do the work.
If you run the Claude Agent SDK on Railway instead of plain API calls: it starts a Claude Code process under the hood, so plan for a long-running service. Local state is lost on restart, there is no session timeout so always set maxTurns, and Anthropic suggests starting at 1 GiB of RAM per agent. Lock permissions down: allowed_tools does not restrict anything in bypassPermissions mode, and for headless agents you pair an allowlist with dontAsk.
Picking models and keeping cost sane
Anthropic's own rule of thumb: Haiku for simple tasks, Sonnet for most production work, Opus for the hardest reasoning. In this system:
- Haiku: classifying replies, gating engagers, tagging signals. High volume, short outputs.
- Sonnet: drafting messages and replies, scoring with a website read.
- Opus: the weekly analytics read and the critic on anything a prospect will see.
Two easy wins: run the weekly batch jobs through the Batch API at half price, and put the stable parts of each prompt (the skill, the ICP, the voice examples) first so they cache. And watch the fan-out: every subagent you add multiplies tokens.
Testing: how you know an agent works
Anthropic's advice is to start with 20 to 50 simple tasks drawn from real failures, grade what the agent produced rather than the path it took, and read the transcripts. For GTM agents that translates to:
- A golden set per skill. 30 real signals with the tier a person gave them. 30 real replies with the reply that was actually sent. Run the skill against them on every change.
- Code checks for the hard rules. No placeholder in a draft, our links only, slots that match the calendar API, no em dash if your brand bans it. A script catches these every time; a model catches them most of the time.
- A model grader only after you calibrate it against your own judgment on a sample.
- Edit rate in production as the north star. If people keep rewriting the drafts, the skill is wrong, whatever the eval says.
What breaks: the list
The failure modes we hit or saw others hit, and the guard for each.
| Failure | Guard |
|---|---|
| A script sends without approval | One module can send; a test fails if anything else calls a send endpoint; Slack approval per message [O2] |
| Double send from a double click or retry | Conditional update on status='pending'; unique event IDs |
| Competitors scored as perfect ICP | A judgment gate that reads their site [O4] |
| Catch-all emails sent as valid | Verify catch-alls separately; "Safe to Send" only |
| Alerts nobody reads | 8 to 12 signals, tier 1 only to Slack |
| Calendar slots hours off | Always pass the lead's time zone; re-check at send |
| A drafted reply that invents a claim | Full thread, facts only from files, lint plus critic, human approval |
| Duplicate CRM records | Upsert by a matching attribute; key people on LinkedIn URL |
| A cron job that hangs forever | Hard timeouts; Railway skips overlapping runs |
| LinkedIn restrictions | 20 invites a day, weekdays, spread out; acceptance above 20 percent[O3] |
| Spam folder | Gmail wants spam rate under 0.3 percent, ideally under 0.1 |
| A skill that never fires | Description says what and when, third person |
Where to start
Don't build eight at once. Build the database and the Slack approval card first, because every agent needs them. Then the reply agent, because it touches revenue the fastest and teaches you the approval loop. Then signals and routing. Content, analytics and GEO come last; they compound, but slowly.
If you want the details behind specific pieces, we wrote up ICP as code, the enrichment waterfall, scoring and routing and signals. More on how we run these: GTM agents.
Frequently asked questions
Do I need the Claude Agent SDK, or is the API enough?
For most of these, plain API calls are enough: the script does the work and Claude makes one decision. Use the Agent SDK when a job needs Claude to use tools in a loop, like researching an account across several sources. It runs a Claude Code process, so host it as a long-running service and set `maxTurns`.
Can I build these without code, in Clay or n8n?
Signals, enrichment and routing, largely yes; Clay runs its own tiering this way. The parts that get hard without code are the shared database, the Slack approval card with a claim-once check, and the learning loop from edits.
Which agent should I build first?
The reply agent. It sits closest to revenue, and it forces you to build the two pieces every other agent needs: the shared database and the approval card.
How do I stop an agent from sending something wrong?
Make one module the only code that can send, require a person's click on every one-to-one message, check the hard rules with code before a draft reaches Slack, and log every send before and after it happens.
Where should the agents run?
Anywhere that gives you an always-on service and scheduled jobs. We use Railway: one web service for Slack and webhooks, plus cron jobs that start, do their work and exit.
Let's build
what your
company needs.
Drop your email. We'll send The Custom Agent Blueprint on what we'd build first for a company like yours, before you ever take a meeting.


