The most architecturally serious agent in the fleet. It writes a personalised outreach message, then hands it to a second model that did not see the writing prompt and can throw it away. It refuses to write for accounts outside the ICP. It caps its own spend daily and monthly. It fetches its instructions live from git, so improving a message pattern is a commit, not a deploy.
Every one of those is a mechanism for not sending something embarrassing to a prospect — which is the only thing that matters when an agent's output leaves the building with your name on it.
.env.
| Job | Generate and validate a personalised outreach message — LinkedIn connection request, InMail, connection follow-up, or cold email 1/2/3 — from the SSO skill playbooks. |
| Harness · archetype | fastapi-litellm · outreach-agent. An always-on Node service with its own health check, state and cost accounting. |
| Trigger | webhook — POST /generate behind AGENT_AUTH_TOKEN. Batch endpoint gated behind a feature flag. |
| Host | Container made-sso-agent on bc-made, /docker/made-sso-agent, image made-sci/sso-agent:0.1.0, port 8080 internal only — not published to the host. |
| Models | Generator claude-sonnet-4-5 · Validator claude-haiku-4-5. Deliberately different tiers. |
| Guardrails | ENABLE_HOLDOUT_VALIDATOR · REFUSE_NOT_ICP · DAILY_COST_CAP_USD · MONTHLY_COST_CAP_USD · COST_ALERT_THRESHOLD_USD · ENABLE_PROMPT_CACHE |
| Skill source | Fetched live from git — MADE-SCI/made-reporting-ux@main : skills/agents/sso-agent/skills, 60-second cache. |
| Health | Up 3 weeks, Docker healthcheck passing, /health → 200 in ~0.2–2ms. |
| RED line | Drafts only. Writes a message record; does not send. Sending is a separate, human-gated step. |
Four phases, and the important thing about the ordering is that two of them can refuse. The ICP gate refuses before spending anything; the validator refuses after spending. An agent that can only succeed is an agent that will eventually succeed at something bad.
AGENT_AUTH_TOKEN. The port is not published to the host — reachable only inside the Docker network or via the Edge Function.REFUSE_NOT_ICP. If the account is out of profile, it will not write. Not "writes something generic" — refuses.linkedin_connection_followup requires a real connectionAcceptedAt, self-hydrated from the follow-up queue. No connection → NOT_CONNECTED.Why this is the important oneThe failure mode of outreach automation is not bad grammar — it's volume of irrelevance. A tool that will write to anyone gets pointed at everyone, and the cost lands on domain reputation and on the recipients' opinion of Made. A tool that refuses is structurally incapable of that.
The secondary benefitIt makes the ICP definition load-bearing. If the gate is refusing accounts a rep thinks are good, that's a real disagreement about targeting — surfaced early, in a specific case, instead of argued abstractly in a planning meeting.
Cost of a refusalZero. It happens before the generator runs.
What it consumesContact, company, buyer segment, persona, modality, engagement history and campaign membership — all from the canonical Transfer identity graph, keyed on MADE-ID.
Why that matters for message qualityThe difference between "I saw you work in cell therapy" and "you presented the allogeneic scale-up data at ISCT" is entirely a function of what the agent was handed. Personalisation is a data problem before it is a prompt problem.
ConsequenceImproving the identity graph improves outreach quality for free. Those two projects are the same project.
linkedin_connection_request, inmail, connection_followup, cold_email_1/2/3, email_on_demand.made-reporting-ux@main, authenticated with a read-only PAT. 60s cache.It also makes the skill the real artifactThe agent is a thin executor. The value is in seven markdown files that a non-engineer can read and argue with. That is the skill library premise made concrete.
TraceabilityEvery output carries the skill version — so "why did it write that" is answerable against a specific commit.
Dead filesemail_cold.md and email_followup.md are aliased to cold_email_1 and cold_email_2 and are otherwise unreachable. Anyone editing them would be editing nothing, with no error.
The mirroring trapThe service's source lives on the VPS at /docker/made-sso-agent, which is not a git repo. The version-controlled copy is a snapshot folder inside made-reporting-ux. A hotfix applied directly on the box is silently reverted by the next rebuild unless someone mirrors it back.
FixDelete the dead aliases. Make the VPS directory a checkout, or make redeploys the only path to changing it.
claude-sonnet-4-5 writes the draft against the playbook's worked examples.claude-haiku-4-5, blind to the generation prompt. Checks banned phrases, specificity, format. Can reject.VALIDATOR_REJECTED. A hard failure, not a warning. Nothing weak gets through with a caveat.{finalText, metadata} — skill version, models used, cost, what context was available.Why a same-model self-check doesn't workA model asked to grade its own output, with its own instructions in context, agrees with itself. It has already decided the answer is good — that's why it produced it. The hold-out breaks that loop by removing the context that caused the decision.
Why a cheaper model is correct hereJudging "is this specific, is this the right length, does it use a banned phrase" is a far easier task than writing. Haiku is sufficient and cheap enough to run on every single generation — and a validator you can't afford to always run isn't a guardrail, it's a spot check.
VALIDATOR_REJECTED correctly. It looks like an error and reads like a failure in the logs. It is the system succeeding: a message that wasn't good enough did not reach a prospect. The right response is to regenerate, and — if it happens often for one type — to fix that playbook.Generalises toparley, and the planned margin and redline, which both specify two-pass validators.
The bugThe prompt builder targeted ~85% of a 600-character cap for every message type. LinkedIn connection follow-ups came out at 510–526 characters — long, pitchy, and asking for a meeting — while the playbook itself specified 250–450 and a soft close.
The fixPer-type soft targets. Follow-ups now aim ~380 with an explicit brevity directive: two to four short sentences, no pitch, no meeting ask. Verified output landed at 380–400.
ResidualOccasional comma splice; a light "we built X so we handle Y" capability line still appears — it matches the playbook's own worked example, so it's the skill, not the model.
generation_metadata — playbook used, pain points, value props, match score, models, cost.Read from the running container's environment. Every one maps to a specific way this agent could embarrass Made or cost money.
| Flag | Protects against | Failure mode without it |
|---|---|---|
| REFUSE_NOT_ICP | Writing to the wrong universe | Volume of irrelevant outreach. Domain reputation damage. The worst outcome on this list. |
| ENABLE_HOLDOUT_VALIDATOR | Sending a weak or off-brand message | A model grading its own homework and always passing. |
| DAILY_COST_CAP_USD | A runaway loop or a bad batch | One misconfigured caller costing a month's budget overnight. |
| MONTHLY_COST_CAP_USD | Slow drift | Steady creep nobody notices, since no aggregate cost ledger exists yet. |
| COST_ALERT_THRESHOLD_USD | Silence before the cap | Hitting the ceiling as a surprise instead of a warning. |
| ENABLE_PROMPT_CACHE | Paying repeatedly for identical context | Playbook and account context re-billed on every call. |
| ENABLE_BATCH_ENDPOINT | Accidental volume | A flag, correctly, rather than an always-on capability. Batch is where ICP discipline is most likely to slip. |
The ICP gate refuses before spending — free, and it catches the expensive mistake (writing to the wrong person). The validator refuses after spending — cheap, and it catches the embarrassing mistake (writing badly to the right person).
Neither can substitute for the other, and having both means the agent's default posture is not sending. It sends only when two separate checks agree. Any new generative agent at Made should copy this shape exactly: gate on relevance before the model, gate on quality after it.
/health response200Why this matters given the rest of the fleetUnlike FORGE, this agent is unambiguously alive — a running process answering requests, with a healthcheck proving it continuously. This is what live should mean everywhere.
ContrastFORGE's live rests on a directory existing. This one rests on a 200.
What the logs showThe visible log window contains only Docker health probes — a GET /health every 30 seconds, nothing else. No POST /generate in the observed window.
Honest caveatThis is a narrow window on a container that has been up three weeks, so it is not proof of zero usage — only that there was none while looking. Real usage volume needs the message table, not the logs.
Next checkQuery generated-message volume by week. That single number determines whether the roadmap or the rollout is the priority.
What happenedThe Dockerfile uses a floating node:22-alpine. On Node v22.23.0 the bundled fetch implementation threw Premature close on every model call — total generation failure from a base-image bump nobody made.
The fixPass an explicit fetch override into both the Anthropic and OpenAI clients so they use globalThis.fetch rather than the SDK's shim.
MODEL_FAILURE after a rebuild, check the fetch override survived — then pin the image.RecommendationPin the base image. A floating tag on a production container is an unscheduled upgrade waiting for the worst moment.
What's missingMessages are generated, validated, reviewed, edited and sent — and nothing routes the result back. Which playbook produces the most replies? Which one gets edited most heavily before sending? Unanswerable today.
Why the data is nearly thereEvery message stores its skill version and full generation_metadata. Human edits happen in a UI that could diff draft against sent. The join is available; nobody built the report.
er_learned_rules table. Worth noticing it appearing twice.WhenOnce usage volume makes the sample meaningful. Which loops back to E-02.
SSO AGENT is the agent other Made agents should be built to resemble. It refuses twice — on relevance before spending, on quality after. Its instructions live in git, so the thing that actually determines output quality is reviewable by a non-engineer and changeable without a deploy. It caps its own spend at two horizons with an alert before the ceiling. It cannot send — a human always stands between it and a prospect. And it is demonstrably alive, with a healthcheck rather than a directory as evidence.
Its weaknesses are all maintenance debt rather than design error: a floating base image, two dead playbook aliases, a source directory on the VPS that isn't a git checkout, and a prompt builder that once silently overrode the skill it was supposed to be executing.
The one open question is the same as SCOUT's. This is a well-built machine and the observed traffic is health probes. Before building the next nine agents, it is worth knowing whether the two best ones are being used — because a fleet's value is realised at the point of use, not the point of deploy.