Multi-Agent Role Cards: How to Design Them (with Templates)
Multi-agent systems fail at the role boundary, not at the model boundary. The model is usually capable; the system breaks because someone wrote a specialist that did not know exactly where its job ended, what it would accept as input, what shape it would return, or what to do when it could not finish. That is what role cards solve.
A role card is the operating contract for a specialist agent. It is not the prompt. It is the source of truth from which the prompt is generated, against which the prompt is validated, and into which lessons learned in production are written back. Every specialist in a production multi-agent fleet should have one, versioned in source control, reviewed in pull requests, and tagged in every run log so old work can be replayed against the card it was authored against.
This piece is the field guide we wish we had when we started: the five-field template, three worked examples from our own production fleet, the non-obvious lessons that only show up after a few hundred runs, and a JSON template you can copy and adapt.
What a role card actually is
A role card is a structured, versioned document that defines a specialist agent on five axes:
- Identity. What this agent is and who it reports to.
- Scope. What this agent does and explicitly does not do.
- Inputs. The schema of the work item it accepts.
- Outputs. The schema of the result it returns.
- Escalation. What it does when it cannot complete the work.
That is it. Five fields. Resist the temptation to add a sixth field for "personality" or "tone of voice"; if it matters, it lives inside the prompt, not in the contract.
The role card is the artifact that the foreman validates against. When the foreman dispatches a work item, it checks the work item against the specialist's input schema. When the specialist returns a result, the foreman checks the result against the output schema. When something fails, the foreman reads the escalation rules from the card and decides what to do. The card is also what onboarding engineers read first when they need to understand the system; the prompt is implementation detail downstream.
For the broader pattern in which role cards live, see our foreman / manager pattern explainer. Role cards without a foreman pattern are useful documentation; role cards with a foreman pattern are an executable contract.
The 5-field template, field by field
Identity
One sentence. What this agent is, in operator language, and who it reports to. Identity is what the agent reads first in its system prompt; it sets the frame for everything else. Bad identity statements are vague ("you are a helpful assistant"); good identity statements are sharp and bound the role.
The shape we use:
"You are the [role name] agent. You report to the [foreman name]. You [one sentence describing the unit of work this agent produces]."
The "you report to" clause matters. It tells the agent that there is a higher authority that owns the plan, and that the agent's job is to do its narrow piece, not to take over decisions about what runs next. Without that clause, specialists drift toward acting like foremen.
Scope
Two paragraphs. The first describes what this agent does, in concrete language, with explicit boundaries. The second describes what this agent does not do, with adversarial language for the most likely scope-violating requests.
The "does not do" half is the part most teams skip and the part that prevents the most failures. A specialist with a well-written exclusion paragraph will return a scope-violation error when handed work outside its lane; a specialist without one will improvise around the boundary and produce subtly wrong outputs that the foreman accepts because the shape looks right.
The shape:
"You [do this and this and this]. You do not [do that or that or that]. If you are asked to [the most likely scope-violating request], return a scope-violation error to the foreman with the field [which-violation]."
Inputs
The typed schema of the work item this agent accepts. Always typed. Always validated by the foreman before dispatch.
We use JSON Schema as the canonical format because it is the lingua franca of structured-output enforcement, but Pydantic, Zod, and similar are equally valid. The card stores the schema; the prompt references it.
Three rules for input schema design:
- Closed object. No
additionalProperties: true. The agent never sees fields it does not expect. - All required fields are required. Optional fields are explicitly marked. Nullable fields are explicitly typed.
- Each field has a one-line description. The descriptions become part of the agent's prompt; they are how the agent understands what the field means.
Outputs
The typed schema of what this agent returns. Same rules as inputs — closed object, explicit required and optional fields, descriptions on every field. The output schema is what the foreman validates when work comes back; if the agent returns shape that does not match, the foreman rejects and either retries with a corrective prompt or fails the run.
Two rules specific to output schemas:
- Always include a confidence field where the agent is producing judgment. Confidence is a number between 0 and 1 with a documented threshold above which the foreman accepts and below which the foreman escalates. Without a confidence field, the foreman cannot tell the difference between a clean result and a low-quality result with the right shape.
- Always include a citation or evidence field where the agent is producing claims. What the agent saw, where it saw it. This is the audit trail. If the output has no evidence field, the agent has no incentive to ground its output, and downstream consumers have no way to verify it.
Escalation
The most important field. Three concrete branches, each one a labeled error code or status that the foreman knows how to handle.
- Retryable failure. Transient tool error, model timeout, rate limit. The foreman retries with backoff.
- Unrecoverable failure. Input does not match the schema in a way the agent cannot work around, the requested entity does not exist, the data is structurally malformed. The foreman fails the run cleanly and surfaces the failure on the kanban.
- Human-needed. Judgment call outside the agent's scope, ambiguous intent, low confidence, or anything the agent has been instructed to escalate (for example, anything involving price, anything involving a personal-data category, anything below a quality threshold). The foreman pauses the run and creates a flashcard for operator review.
Write the escalation field before inputs and outputs. We say this in every internal review and we say it again here because it is the single most leveraged piece of advice in this guide. Writing escalation first forces you to think about how the agent fails before you think about how it succeeds. Most role-card pathologies are escalation pathologies — the agent did not know what to do when something went wrong, so it improvised.
Worked example one: lead-discovery agent
type LeadDiscoveryRoleCard = {
version: "1.4.0"
identity:
"You are the Lead-Discovery agent. You report to the Sales Foreman. " +
"You find companies that match the operator's ICP and return a structured list of candidates."
scope: {
includes: [
"Search public sources for companies matching the ICP definition",
"Score each candidate on fit against the criteria provided",
"Deduplicate against the exclusion list and return the top N candidates"
]
excludes: [
"Enriching contacts at the discovered companies",
"Writing outreach copy for any candidate",
"Contacting any person at any candidate",
"Persisting candidates to the CRM"
]
violation_response:
"If asked to enrich, write, contact, or persist, return error_code 'scope_violation' " +
"with the offending request quoted in the violation_detail field."
}
inputs: {
icp: {
industries: string[] // SIC or NAICS-style descriptors
headcount_min: number
headcount_max: number
geographies: string[] // ISO 3166 country codes
tech_stack_includes: string[]
tech_stack_excludes: string[]
}
max_candidates: number // default 50, hard cap 200
exclusion_list: string[] // domains already in pipeline
}
outputs: {
candidates: Array<{
name: string
domain: string
headcount_estimate: number
industry: string
evidence_url: string // public source for the fit claim
fit_score: number // 0..1
}>
notes: string | null
confidence: number // 0..1, threshold 0.7
}
escalation: {
retryable: ["search_rate_limit", "model_timeout", "transient_tool_error"]
unrecoverable: ["malformed_icp", "exclusion_list_invalid", "no_search_provider_available"]
human_needed: [
"fewer_than_ten_candidates_above_threshold",
"icp_definition_too_broad",
"icp_definition_too_narrow"
]
}
}
Notes on this card. The exclusion_list field is part of inputs, not something the agent fetches itself; the foreman computes it from the CRM and hands it in. That keeps the agent stateless. The evidence_url field forces grounding — the agent must produce a real public URL for each candidate, not invent one. The fit_score and the run-level confidence are different: fit score is per-candidate, confidence is per-result. Both are needed. Threshold values (the 0.7 confidence threshold) live in the card so the foreman knows when to escalate.
Worked example two: outreach-personalization agent
type OutreachPersonalizationRoleCard = {
version: "2.1.3"
identity:
"You are the Outreach-Personalization agent. You report to the Outreach Foreman. " +
"You write the first-touch message for a single contact, grounded in a recent signal."
scope: {
includes: [
"Draft one subject line and one body for one contact",
"Cite the signal that justifies the personalization",
"Apply the campaign template's tone and constraints"
]
excludes: [
"Drafting follow-up messages or multi-step sequences",
"Sending any message",
"Booking any meeting",
"Combining multiple contacts into one message",
"Generating signals (signals must be provided in input)"
]
violation_response:
"If asked to send, schedule, or generate signals, return error_code 'scope_violation'."
}
inputs: {
contact: {
name: string
role: string
company_domain: string
seniority: "ic" | "manager" | "director" | "vp" | "c_level"
}
company: {
name: string
industry: string
headcount_estimate: number
}
signal: {
type: "job_change" | "funding" | "hiring_pattern" | "tech_change" | "news_mention"
summary: string
source_url: string
observed_at: string // ISO 8601
}
campaign_template: {
tone: "direct" | "consultative" | "warm"
max_subject_chars: number
max_body_chars: number
banned_phrases: string[]
}
}
outputs: {
subject: string
body: string
signal_citation: string // verbatim phrase referencing the signal
confidence: number // 0..1, threshold 0.65
}
escalation: {
retryable: ["model_timeout", "rate_limit"]
unrecoverable: [
"missing_signal",
"signal_older_than_60_days",
"banned_phrase_unavoidable"
]
human_needed: [
"confidence_below_threshold",
"signal_implies_sensitive_topic", // layoffs, leadership ouster, regulatory action
"contact_role_mismatches_persona"
]
}
}
Notes on this card. The signal is required input, not something the agent goes hunting for; that keeps personalization deterministic and auditable. The banned_phrases list is enforced by the agent and validated by the foreman; "circle back," "synergy," and other phrases the operator hates never appear in output. The signal_implies_sensitive_topic escalation is a guardrail — when the signal is "the CEO was just fired," the agent does not draft an opportunistic message; it escalates to the operator. This is the kind of judgment that role cards make explicit and prompts cannot reliably enforce alone.
Worked example three: reply-handler agent
type ReplyHandlerRoleCard = {
version: "1.2.0"
identity:
"You are the Reply-Handler agent. You report to the Inbound Foreman. " +
"You classify inbound replies and propose the next action; you never send and never book."
scope: {
includes: [
"Classify intent of an inbound message in the context of its thread",
"Propose one next action for the operator to confirm",
"Detect unsubscribe and out-of-office automatically"
]
excludes: [
"Sending any reply",
"Booking any meeting",
"Updating CRM stage without operator confirmation",
"Drafting an actual reply body (a separate agent does that)"
]
violation_response:
"If asked to send, book, or update, return error_code 'scope_violation'."
}
inputs: {
inbound_message: {
from: string
subject: string
body: string
received_at: string // ISO 8601
}
thread_history: Array<{
direction: "outbound" | "inbound"
sent_at: string
summary: string
}>
contact_record: {
name: string
role: string
company_domain: string
stage: string // CRM stage
}
}
outputs: {
intent: "interested" | "not_interested" | "unsubscribe" | "out_of_office" | "question" | "ambiguous"
proposed_next_action:
| { kind: "send_followup", template_id: string }
| { kind: "book_meeting", suggested_slots: string[] }
| { kind: "mark_unsubscribed" }
| { kind: "wait_until", date: string }
| { kind: "escalate_to_operator" }
confidence: number // 0..1, threshold 0.75
rationale: string // why this intent and action
}
escalation: {
retryable: ["parse_error_on_body", "model_timeout"]
unrecoverable: ["empty_body", "thread_history_inconsistent"]
human_needed: [
"intent_ambiguous",
"message_contains_price_question",
"message_contains_legal_or_contract_question",
"confidence_below_threshold"
]
}
}
Notes on this card. The proposed_next_action is a tagged union; every kind is explicit, every kind has its own required fields. The agent cannot return "send a reply with this exact text" because that is a different specialist's job. Price and legal questions always go to the operator — those are categories where a wrong answer is expensive and the cost of a human round-trip is much lower than the cost of a wrong autonomous reply. The rationale field is for the operator's eyes; it is what they read in the kanban card before deciding whether to approve.
A JSON template you can copy
This is the canonical shape we keep in source control. Adapt for your own conventions; the structure is what matters.
{
"version": "0.1.0",
"agent_name": "your-specialist-name",
"identity": "You are the [Role] agent. You report to the [Foreman]. You [one-sentence unit of work].",
"scope": {
"includes": [
"Concrete thing the agent does",
"Concrete thing the agent does",
"Concrete thing the agent does"
],
"excludes": [
"Concrete thing the agent does NOT do",
"Concrete thing the agent does NOT do",
"Concrete thing the agent does NOT do"
],
"violation_response": "If asked to [most likely violation], return error_code 'scope_violation' with violation_detail."
},
"inputs": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["field_a", "field_b"],
"properties": {
"field_a": { "type": "string", "description": "What this field is" },
"field_b": { "type": "number", "description": "What this field is" }
}
},
"outputs": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["result", "confidence"],
"properties": {
"result": { "type": "string", "description": "The unit of work" },
"evidence": { "type": "string", "format": "uri", "description": "Source for the claim" },
"confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "Self-rated confidence" }
}
},
"escalation": {
"retryable": ["transient_tool_error", "model_timeout", "rate_limit"],
"unrecoverable": ["malformed_input", "required_resource_missing"],
"human_needed": ["confidence_below_threshold", "judgment_required"]
},
"thresholds": {
"confidence_min": 0.7,
"max_retries": 3,
"max_tokens_per_run": 8000
},
"allowed_tools": [
"search.public_web",
"memory.read"
],
"model_binding": {
"preferred": "claude-sonnet",
"fallback": "claude-haiku"
}
}
A note on the additional fields beyond the five core ones: thresholds, allowed_tools, and model_binding are operational metadata, not part of the contract per se. The contract is identity-scope-inputs-outputs-escalation. The operational metadata is how this card is run; it changes more often than the contract and lives in the same artifact for convenience.
Non-obvious lessons from production
A few patterns that only became obvious after running these cards in production for a year:
Version every card. Use semantic versioning. Tag the version in every run log. When you investigate a regression, you want to know exactly which card the run was authored against. We have caught quality regressions that were entirely explained by an unannounced card change six weeks earlier; the version-in-log discipline made the diagnosis a five-minute query instead of a half-day investigation.
Write cards adversarially. When you draft a card, ask yourself: what is the worst kind of work item the foreman could hand this agent? What is the most ambiguous request? What is the request that looks valid but is actually scope-violating? Write inputs and scope to handle those cases. Test fixtures should include adversarial inputs, not just happy-path ones.
The card is not the prompt; the prompt is generated from the card. We compile prompts from cards via a templating layer. The card is the source of truth; the prompt is a derived artifact. That way, the card is reviewed; the prompt is regenerated. Without this, the card and prompt drift out of sync within weeks and the card stops being trustworthy documentation.
Cards belong to roles, not models. If you swap a specialist from one model to another, the card does not change. Only the model binding does. This is the discipline that makes specialists swappable. If your card includes model-specific quirks (special tokens, model-specific tool-calling formats), you have leaked implementation detail into the contract.
Audit cards quarterly. Roles drift. New use cases get added; old constraints get forgotten. Once a quarter, every card is reviewed against the current production logs: do the inputs reflect what the foreman actually dispatches? Do the outputs reflect what the agent actually returns? Do the escalations reflect the failures that actually happen? The audit catches drift before it becomes a regression.
Cards in a graph beat cards in a folder. We store role cards as nodes in our memory graph, with edges to the foreman they report to, the runs that used them, and the regressions that retired them. That makes "which cards changed in the last month, and what was their impact on quality" a query, not an investigation. For the broader memory-layer thinking, see our agentic operating system glossary entry.
Where to put this in your stack
Role cards live alongside prompts in source control. We use a roles/ directory with one subfolder per role; each subfolder contains the card (role.json), the prompt template (prompt.md), the regression fixtures (fixtures/), and a changelog. Pull requests against any of those files require a review from someone who owns the foreman that uses the role.
In runtime, the foreman loads the cards from the same source-controlled artifacts at startup, and every run log records the card version that was active at dispatch time. When a card changes, the foreman is restarted; the change is part of the deploy, not a hot-swap.
For the larger architectural picture role cards fit into, see our how to build a multi-agent AI system guide and the agentic workforce 2026 piece. For the broader vocabulary of the orchestration patterns role cards plug into, see our multi-agent orchestration glossary entry and our agentic AI glossary entry.
A multi-agent system can survive a lot of imperfect prompts, occasional bad model snapshots, and the regular surface-level chaos of agentic work. What it cannot survive is fuzzy role definitions. The role card is the discipline that turns "we have agents" into "we have a system." It is the artifact we put the most care into and the artifact we go back to most often when something goes wrong. Write the cards before the prompts, version them, audit them, and the rest of the architecture will tolerate a lot more fragility than you think.