M Made Scientific MADE OS · agent anatomy · SSO AGENT

SSO AGENT — outreach generation, end to end

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.

Live-verified 2026-08-04 — container healthy, up 3 weeks, health endpoint returning 200. Config read from the running .env.
Verified working Works, with a limit Believed working, isn't Not built
At a glance

The operational facts

JobGenerate 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 · archetypefastapi-litellm · outreach-agent. An always-on Node service with its own health check, state and cost accounting.
TriggerwebhookPOST /generate behind AGENT_AUTH_TOKEN. Batch endpoint gated behind a feature flag.
HostContainer 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.
ModelsGenerator claude-sonnet-4-5 · Validator claude-haiku-4-5. Deliberately different tiers.
GuardrailsENABLE_HOLDOUT_VALIDATOR · REFUSE_NOT_ICP · DAILY_COST_CAP_USD · MONTHLY_COST_CAP_USD · COST_ALERT_THRESHOLD_USD · ENABLE_PROMPT_CACHE
Skill sourceFetched live from gitMADE-SCI/made-reporting-ux@main : skills/agents/sso-agent/skills, 60-second cache.
HealthUp 3 weeks, Docker healthcheck passing, /health → 200 in ~0.2–2ms.
RED lineDrafts only. Writes a message record; does not send. Sending is a separate, human-gated step.
The run

What happens between the request and a message you'd actually send

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.

Phase A

Gate first — decide whether to write at all

The cheapest refusal is the one that happens before the model is called.
A1
Auth
Bearer AGENT_AUTH_TOKEN. The port is not published to the host — reachable only inside the Docker network or via the Edge Function.
Free
A2
Hydrate the account
Pulls the contact, company, segment, persona and engagement context from Transfer using the canonical identity graph.
Free · SQL
A3
ICP gate ★
REFUSE_NOT_ICP. If the account is out of profile, it will not write. Not "writes something generic" — refuses.
Free · saves spend
A4
Cost preflight
Daily and monthly caps checked before generating. Over cap → refuse, don't degrade.
Free
A5
Type-specific precondition
linkedin_connection_followup requires a real connectionAcceptedAt, self-hydrated from the follow-up queue. No connection → NOT_CONNECTED.
Free
A★

The ICP gate is the highest-value line of code in the agent

Verified in configREFUSE_NOT_ICP
What it doesBefore any model call, SSO AGENT checks whether the target is actually in Made's ideal customer profile. If not, it returns a refusal. It does not fall back to a generic template.

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.

Note it's a config flag, not a hardcoded rule. Which means it can be turned off. Anyone doing so should record why — this is the guarantee the whole agent rests on.

Cost of a refusalZero. It happens before the generator runs.

A-DEP

It reads the relationship graph instead of re-deriving it

Reuse, not rebuild

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.

Inherited limitation: where the graph is thin, the message is thin. The 31% contact-to-account rollup gap lands here directly — a contact with no resolved company gets a materially weaker message, and the agent has no way to know what it's missing.

ConsequenceImproving the identity graph improves outreach quality for free. Those two projects are the same project.

Phase B

Fetch the playbook — live from git

The instructions are not baked into the image. They are pulled from the repo at request time, cached 60 seconds.
B1
Map type → skill file
Seven files: linkedin_connection_request, inmail, connection_followup, cold_email_1/2/3, email_on_demand.
Free
B2
Fetch from GitHub
Raw fetch against made-reporting-ux@main, authenticated with a read-only PAT. 60s cache.
Free
B3
Sanitize
The fetched markdown is sanitized before it reaches a prompt — a fetched instruction file is untrusted input.
Free
B4
Version-stamp
The skill version is recorded on the output, so a message can be traced to the exact playbook that produced it.
Free
B★

Live skill fetch means improving outreach is a commit, not a deploy

Verified in running config60s cache
Why this is the best structural decision in the agentMessage quality is learned slowly, from feedback, in small increments. If every tweak required rebuilding a Docker image, the tweaks would not happen. Because the playbooks live in git and are fetched per request, a copy change is a pull request — reviewable, diffable, revertible, and live within a minute.

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.

Playbook files7
Cache TTL60s
Deploy needed to change copynone

TraceabilityEvery output carries the skill version — so "why did it write that" is answerable against a specific commit.

B-LIM

Two dead playbook aliases, and a repo that must be mirrored by hand

Maintenance debt

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.

This has already happened once. A follow-up brevity fix was applied live on 2026-06-20 and only mirrored into the repo two days later. Two days in which a rebuild would have quietly undone it.

FixDelete the dead aliases. Make the VPS directory a checkout, or make redeploys the only path to changing it.

Phase C

Generate, then let a different model try to kill it

A Sonnet-class model writes. A Haiku-class model that never saw the writing prompt grades it and can reject outright.
C1
Build the prompt
Playbook + hydrated account context + a per-type length target. Prompt caching on to cut repeat cost.
Free
C2
Generate
claude-sonnet-4-5 writes the draft against the playbook's worked examples.
Metered
C3
Hold-out validator ★
claude-haiku-4-5, blind to the generation prompt. Checks banned phrases, specificity, format. Can reject.
Cheap
C4
Reject = HTTP 500
VALIDATOR_REJECTED. A hard failure, not a warning. Nothing weak gets through with a caveat.
C5
Return draft + metadata
{finalText, metadata} — skill version, models used, cost, what context was available.
Free
C★

The validator is a hold-out, and that word is doing all the work

Verifiedhaiku-4-5
The mechanismThe validator does not see the generation prompt. It sees the output and a rubric. So it cannot be talked into approving something by the same framing that produced it.

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.

Read 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.

C-LIM

Message length needed hand-tuning, and the fix reveals a design tension

Fixed, worth knowing

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.

The tension it exposes: the playbook said 250–450 and the prompt builder said ~510, and the code won. A skill-driven agent whose executor silently overrides the skill is only nominally skill-driven. The playbooks are the spec; the builder should read them, not compete with them.
Before510–526 chars
After380–400 chars

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.

Phase D

Hand off to a human — and stop

The agent's job ends at a draft. Sending is somebody's decision.
D1
Persist the draft
Message plus generation_metadata — playbook used, pain points, value props, match score, models, cost.
Free
D2
Show the reasoning
The app renders a "what was used" panel — which angle, which pain points, why this account matched.
Free
D3
Human reviews
Edit, approve, or discard. Previous messages to the same contact are shown to avoid repetition.
Free
D4
Sending is elsewhere
A separate, human-gated step. The agent has no send capability at all.
The "what was used" panel is underrated. It turns the agent from an oracle into a colleague: a rep can see the message and the argument for it, and disagree with the argument specifically. That is what makes corrections useful rather than just "make it better." Every generative agent should ship one.
Guardrails

Six flags, and what each one is actually protecting against

Read from the running container's environment. Every one maps to a specific way this agent could embarrass Made or cost money.

FlagProtects againstFailure mode without it
REFUSE_NOT_ICPWriting to the wrong universeVolume of irrelevant outreach. Domain reputation damage. The worst outcome on this list.
ENABLE_HOLDOUT_VALIDATORSending a weak or off-brand messageA model grading its own homework and always passing.
DAILY_COST_CAP_USDA runaway loop or a bad batchOne misconfigured caller costing a month's budget overnight.
MONTHLY_COST_CAP_USDSlow driftSteady creep nobody notices, since no aggregate cost ledger exists yet.
COST_ALERT_THRESHOLD_USDSilence before the capHitting the ceiling as a surprise instead of a warning.
ENABLE_PROMPT_CACHEPaying repeatedly for identical contextPlaybook and account context re-billed on every call.
ENABLE_BATCH_ENDPOINTAccidental volumeA flag, correctly, rather than an always-on capability. Batch is where ICP discipline is most likely to slip.
The pattern worth extracting

Two independent refusals, at different costs

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.

Evidence

What the sweep could and could not confirm

E-01

Confirmed: the service is genuinely healthy

Verified 2026-08-04
Container stateup 3 weeks (healthy)
/health response200
Response time0.19–1.9 ms
Docker healthcheckpassing every 30s

Why 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.

E-02

Not confirmed: that anyone is using it

Same shape as SCOUT

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.

But it rhymes with SCOUT's 11 briefs. Two of the fleet's most carefully-built agents show little evidence of routine use. If that holds up under a proper usage query, the fleet's real constraint is adoption, and building the next nine agents will not address it.

Next checkQuery generated-message volume by week. That single number determines whether the roadmap or the rollout is the priority.

E-03

A known operational trap: the Node 22 fetch shim

Fixed, fragile

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.

Still fragile: the base image is still a floating tag. A future rebuild can pull a new Node and reintroduce a comparable break. If generation ever fails with 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.

E-04

No feedback loop from outcomes back into the playbooks

Future

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.

The heaviest-edited playbook is the most valuable signal in this system — it is a human telling you exactly what the skill gets wrong, in specifics, for free. That is the same unharvested-corrections pattern as the empty er_learned_rules table. Worth noticing it appearing twice.

WhenOnce usage volume makes the sample meaningful. Which loops back to E-02.

Verdict

The reference implementation. Copy this shape.

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.