Skip to main content
Research

Orchestrating a multi-agent claims workflow: topology, handoffs and blast radius

Four agents given a shared goal will find an answer. Ask them six months later how they found it and you get a transcript, not a decision record.

Agentic orchestrationMulti-agent orchestrationWorkflow state machinesAgent handoff contractsTool permission scopingBlast radius and bounded autonomy

One supervisor, four agents, one state machine

Supervisor · chooses the next step, performs none of them
intaketriagecover checkevidencedraft
Human releasenothing outward-facing leaves without it

Start with the run nobody could explain. Four agents, given a claim and a shared objective, allowed to pass work between themselves as they saw fit. It produced a reasonable answer most of the time, and when it did not, the reconstruction was archaeology. Which agent decided the policy did not cover the loss? The one that said so was quoting the one before it, which had summarised a retrieval result that no longer existed by the time anybody looked. There was a transcript. There was no decision record. A transcript tells you what was said, and the question in front of you is what was decided, by which component, on what evidence, and whether it was entitled to decide it at all.

That failure is not a modelling failure and a better model does not fix it. It is a topology failure. The system had no place where a decision was recorded as a decision, because the topology lived inside a conversation rather than outside it.

This piece is about the version that replaced it: a claims triage and correspondence workflow in general insurance, in the Australian market, decomposed into a supervisor and four task agents, with the topology held in a deterministic state machine on AWS Step Functions, case state in Amazon Aurora PostgreSQL, and tools as AWS Lambda functions in front of the policy administration system. The agents run on Amazon Bedrock. What follows is the design reasoning and the failure modes it answers for.

The workload and the constraint

A claim arrives as a description written by a person under stress, a policy number, and a handful of attachments of variable quality. Somebody has to work out what kind of claim it is, whether the policy in force at the date of loss responds to it, what evidence is missing before it can progress, and then write back to the customer in a way that is accurate about all three.

Three constraints shaped every decision below, and they were understood before any of it was designed.

The first is that the policy administration system is the system of record and it is not yours to write to. Read access was available. Write access was a change request against a platform with its own release train, its own risk owner and a queue measured in quarters. Any design that assumed an agent could update a claim directly was not a design, it was a proposal for somebody else to fund.

The second is that outbound correspondence about cover is a regulated communication. A wrong statement about whether a policy responds is not a service miss to be apologised for later. It creates an obligation, and unwinding it is a remediation exercise with a compliance record attached.

The third is the one that decided the architecture. The hard part of this workload is not generation. Drafting a clear letter is the easiest thing in the whole system. The hard part is deciding when a step has enough information to proceed, and that is a control problem, not a language problem. Once you accept that, the question stops being which model to use and becomes where control lives.

Why the agents do not talk to each other

The elegant design is the one where agents negotiate. You give each one a role, a shared goal and the ability to address the others, and you let the division of labour emerge. It demonstrates beautifully. It is genuinely capable. We lost time on it, and the reasons it failed are worth more to you than the design that replaced it.

Non-determinism compounds. A single model call has a distribution of outputs, and you can live with that because you can evaluate it. Chain four calls where each one conditions the next on free text, and the distribution you are actually shipping is over paths, not over answers. Two runs of the same claim take different routes, touch different tools and arrive at superficially similar conclusions by different means. You cannot regression test that, because there is no stable thing to assert against. The tests you can write are so loose that they pass while the system is deteriorating.

Replay is impossible in the way that matters. You can store every message and still be unable to answer why. Reconstructing intent from a transcript means inferring, months later, which utterance was load-bearing, and the honest version of that answer is a guess with good grammar.

Agreement is not verification. Two agents that concur are not two independent checks. They are frequently one error, restated with more confidence than it started with, because the second agent conditioned on the first rather than on the record.

And the cost profile is unbounded in a direction nobody budgets for. Conversation length is emergent, so the token cost of a claim is a random variable with a long tail, and the tail is populated by exactly the cases that are already going badly.

The decision that follows is a boundary, not a verdict on agent autonomy. Judgement belongs inside a step, where it is bounded, evaluable and cheap to test. Control belongs outside every step, in something deterministic that can be read, replayed and asserted against. The agents are still doing the work. They are simply no longer deciding the shape of the work while they do it.

Supervisor and four task agents

The supervisor is not a chairperson. It is a classifier over case state that answers one question: given what is known about this claim right now, what is the next step. It returns a decision. It does not perform one, and it holds no tools of its own. That distinction is the whole design in one line, because a component that both chooses and acts is a component whose choices you cannot audit separately from its actions.

Topology of a multi-agent claims triage and correspondence workflow. A control plane invokes a supervisor that picks the next step and holds no tools of its own. A single trunk runs to four task agents, triage, cover check, evidence and correspondence, each scoped to exactly one tool. The three read tools resolve against the policy administration system; the draft store path ends at a human decision step. A dashed boundary marks the autonomy envelope, inside which only reversible actions run.
Topology of a multi-agent claims triage and correspondence workflow. A control plane (AWS Step Functions and Amazon Aurora PostgreSQL case state) invokes a supervisor that picks the next step and holds no tools of its own. A single trunk runs to four task agents, triage, cover check, evidence and correspondence, each scoped to exactly one tool. The three read tools resolve against the policy administration system; the draft store path ends at a human decision step. A dashed boundary marks the autonomy envelope, inside which only reversible actions run.

Underneath it sit four task agents, and the division is by permission before it is by capability. Triage classifies the claim and routes it. Cover check reads the policy wording against the claim facts. Evidence reads the attachments and reports what is present and what is missing. Correspondence drafts the reply. A single agent could plausibly do all four. Four agents exist because each one needs a different set of tools, a different data reach and a different evaluation set, and because the alternative is one identity holding the union of every permission any step ever needs.

That is the useful reframing. An agent is a unit of permission before it is a unit of capability. If two pieces of work need the same tools, the same data and the same standard of correctness, they are one agent no matter how different they read in a prompt. If they need different reach, they are separate no matter how convenient it would be to merge them.

The supervisor holding no tools is not fastidiousness. It means the component with the broadest view of the case is the component least able to change anything, so a supervisor that reasons its way to a wrong conclusion produces a bad routing decision rather than a bad write.

The handoff contract

The next question is what actually crosses the line between one agent and another. The answer is not a transcript, and getting that wrong is how systems quietly revert to the design in the previous section.

What crosses is a typed envelope. It carries the case identity, the step that produced it, findings as structured values rather than prose, a citation on every finding pointing at the record or the wording clause it came from, an explicit list of what could not be established, and an advisory suggestion for the next step that the supervisor is free to ignore.

handoff.tsTypeScript
// What crosses a boundary between two agents. Deliberately not a transcript.
// Every field here exists because its absence caused a specific failure in
// the negotiating version of this system.
export interface Handoff {
  caseId: CaseId;              // from the execution context, never the model
  producedBy: StepId;
  caseVersion: number;         // optimistic concurrency, see the stale-read note

  findings: Finding[];
  /** What this step looked for and could not establish. Distinct from an
      empty findings array, which only means the step found nothing. */
  unresolved: UnresolvedItem[];

  /** Advisory only. The supervisor reads it and is free to ignore it.
      Control flow is never taken from a field an agent populated. */
  suggestedNext?: StepId;

  /** The one free-text field. Retained for human review, never read by
      another agent. Prose that crosses a boundary and changes behaviour on
      the other side is negotiation with extra steps. */
  rationaleForHumans: string;
}

export interface Finding {
  kind: FindingKind;           // closed set, not a label the model invents
  value: string | number | boolean;
  confidence: number;
  /** Mandatory. A finding with no pointer to the record or the wording
      clause it came from cannot be acted on downstream. */
  citation: { source: "claim_record" | "policy_wording" | "document"; ref: string;
              corpusVersion?: string };
}

export interface UnresolvedItem {
  kind: FindingKind;
  reason: "absent" | "unreadable" | "not_searched" | "out_of_scope";
}

One field holds free text, and it is quarantined: a rationale, retained for human review, never read by another agent. The moment prose crosses a boundary and conditions behaviour on the other side, you have rebuilt negotiation with extra steps.

Inputs cross by reference, not by value. The evidence agent receives document identifiers, not document contents. This is partly context economy and partly replay, but mostly it is data minimisation: personal information that never enters a payload cannot be logged, cached, retried into a queue or carried into a model context that outlives the step.

The state machine holds position

The state machine owns the topology. Every transition is a state, every state is named, and the whole definition is a static artefact you can read, diff and review before anything runs. Nothing about the path a claim takes is inferred at runtime by a model.

claims-workflow.asl.jsonAmazon States Language
{
  "Comment": "The machine owns the path. The models own the judgement inside a step.",
  "StartAt": "LoadCase",
  "States": {
    "LoadCase": {
      "Type": "Task",
      "Comment": "Case state is read from Aurora at the top of every pass, never carried in the execution payload.",
      "Resource": "${LoadCaseFunctionArn}",
      "ResultPath": "$.case",
      "Next": "Supervise"
    },

    "Supervise": {
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:bedrockagentruntime:invokeAgent",
      "Parameters": {
        "AgentId": "${SupervisorAgentId}",
        "AgentAliasId": "${SupervisorAliasId}",
        "SessionId.$": "$.case.caseId",
        "InputText.$": "States.JsonToString($.case.summary)"
      },
      "ResultSelector": { "decision.$": "$.completion.decision" },
      "ResultPath": "$.supervisor",
      "Retry": [
        { "ErrorEquals": ["States.TaskFailed", "ThrottlingException"],
          "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2 }
      ],
      "Catch": [ { "ErrorEquals": ["States.ALL"], "Next": "HandToPerson" } ],
      "Next": "RouteNextStep"
    },

    "RouteNextStep": {
      "Type": "Choice",
      "Comment": "The only place the path branches. A decision outside this closed set routes to a person rather than being interpreted.",
      "Choices": [
        { "Variable": "$.supervisor.decision", "StringEquals": "TRIAGE",   "Next": "Triage" },
        { "Variable": "$.supervisor.decision", "StringEquals": "COVER",    "Next": "CoverCheck" },
        { "Variable": "$.supervisor.decision", "StringEquals": "EVIDENCE", "Next": "Evidence" },
        { "Variable": "$.supervisor.decision", "StringEquals": "DRAFT",    "Next": "Correspondence" },
        { "Variable": "$.case.transitions", "NumericGreaterThan": 12,      "Next": "HandToPerson" }
      ],
      "Default": "HandToPerson"
    },

    "Correspondence": {
      "Type": "Task",
      "Comment": "Writes a draft and stops. The send path does not exist in this machine.",
      "Resource": "${CorrespondenceAgentFunctionArn}",
      "ResultPath": "$.draft",
      "Next": "AwaitRelease"
    },

    "AwaitRelease": {
      "Type": "Task",
      "Comment": "Outward-facing, so the execution pauses on a task token until a person releases it.",
      "Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
      "Parameters": {
        "FunctionName": "${QueueForReleaseFunctionArn}",
        "Payload": { "caseId.$": "$.case.caseId", "draftId.$": "$.draft.id",
                     "taskToken.$": "$$.Task.Token" }
      },
      "HeartbeatSeconds": 86400,
      "Next": "RecordAndLoop"
    }
  }
}

There is a distinction worth being exact about, because getting it wrong causes a class of bug that is miserable to find. Aurora holds case state, which is what is true about the claim. The state machine holds position, which is where this claim has got to in the workflow. Put case state into the execution payload and you have two copies of the truth, diverging from the first retry, with the older one winning whenever a step reads from the payload instead of the row.

Retries are not an exception path, they are the normal operation of a distributed system, so every step that changes anything carries an idempotency key derived from the case identity and the step identity. A step that runs twice with the same key produces one effect. Without that, a transient timeout on a step that has already written becomes a duplicate finding, and duplicate findings are how a claim ends up with two contradictory cover assessments and no way to tell which one is current.

The part that pays for itself later is the execution history. A managed state machine records every transition, its input, its output and its timing, as a matter of course. That is the audit artefact, and you did not have to build it, agree its schema or find somewhere to put it. When somebody asks in six months why this claim went that way, the answer is a record rather than a reconstruction.

Tool permissions per agent

Every agent runs under its own identity, with its own action group and its own execution role. The triage agent cannot read documents. The evidence agent cannot read the policy wording corpus. The correspondence agent can write a draft and cannot send anything anywhere.

Enforcement is in the execution role and in the query, never in the prompt. A prompt is an instruction to a probabilistic system and it is not an access control mechanism. If the only thing standing between an agent and an action it should not take is a sentence asking it not to, you have documented a policy rather than implemented one.

cover-check-tool.tsTypeScript
// One tool contract, as given to one agent. Two things are structural rather
// than conventional: the action class, which decides whether the call can run
// unattended, and the absence of a caseId argument.
export const readCoverPosition = defineTool({
  name: "read_cover_position",
  agent: "cover-check",
  actionClass: "read",          // read | write_proposal | outward_facing

  // The model supplies the date of loss and the peril. It does not supply the
  // claim it is working on. Case identity arrives from the execution context,
  // so a model that hallucinates an identifier cannot reach another customer.
  input: z.object({
    dateOfLoss: z.string().date(),
    peril: PerilEnum,
  }),

  async handler(input, ctx: ToolContext) {
    // ctx.caseId is injected by the runtime from the Step Functions execution.
    // It is not in the input schema, so it is not in the model context, so it
    // is not a value the model can get wrong.
    const claim = await claims.read(ctx.caseId, { role: ctx.executionRole });

    // Entitlement is applied in the query, not filtered afterwards. Rows this
    // execution is not entitled to see are never returned to be filtered.
    return wording.lookup({
      productId: claim.productId,
      // Pinned to the version in force at the date of loss, and the version is
      // returned so it can be recorded on the finding.
      asAt: input.dateOfLoss,
      peril: input.peril,
    });
  },
});

One detail carries more weight than it looks like it should. The case identity is bound from the execution context and injected into every tool call. It is never a parameter the model supplies. Anything the model can express it will eventually express wrongly, usually while doing something else entirely, and a model-supplied identifier on a read against a claims system is a cross-customer data exposure waiting for an unusual day. The tool signature simply does not have that argument.

Writes to the system of record do not exist. The write path is a proposal: the tool records an intended change, addressed to the case, which a person releases. This began as a constraint imposed by the platform release train, and it turned out to be the right design regardless. The queue of proposals is a far better artefact than a queue of completed writes, because it is reviewable before it is consequential.

Blast radius sets the ceiling

Blast radius is the useful unit here, and it has a precise definition: the set of things that are different if this step ran wrongly and nobody noticed. Not what the step is allowed to do. What survives the mistake.

Three questions decide the autonomy of any single action. Is it reversible, and reversible by the team that owns the system rather than in principle. Is it visible, meaning would anyone find out without being told. Is it outward-facing, meaning does it reach the customer, a regulator or a system somebody else owns. Reversible, visible and internal actions run unattended. Anything outward-facing stops and waits for a person.

Autonomy is granted per action, not per agent, and never per model quality. The ceiling is set by the cost of being wrong, not by the capability of the thing doing the work. This is the point where a design conversation usually goes astray, because a better model is offered as an argument for a higher ceiling, and it is not one. A more capable model changes how often you are wrong. It does not change what happens when you are.

So correspondence never sends. The agent that drafts the reply writes to a draft store and stops, and a person releases it. That is not a maturity stage to be graduated from later. It is the correct terminal design for an outward-facing regulated communication, and stating it plainly is more useful than promising to remove the human once confidence is high enough, which is a promise nobody should make about this class of action.

Containment is structural. One execution per claim, no shared mutable context between executions, and a transition cap per execution. A claim that exceeds the cap stops and is handed to a person, and the stop is recorded as an incident rather than retried, because a workflow that cannot terminate is telling you something a retry will not fix. A step that fails repeatedly within a window opens a circuit breaker and halts new executions rather than working through the backlog at speed. The failure you are guarding against is not one bad claim. It is one bad deployment applied uniformly to every claim that arrives before somebody notices.

Failure modes you will meet

These are the classes of failure this architecture has to answer for. They are not exotic. They are what a workflow of this shape does when it meets a real estate.

  • Partial completion. Cover check succeeded, evidence failed, and the case is half assessed. There is no transaction spanning four agents and two systems, so each step that writes needs a compensating step that is explicitly designed, and the case needs a state that means partially assessed rather than a state that means done.
  • Duplicate side effects. Covered by idempotency keys, but only if every write path has one. The one that gets missed is always the write nobody thought of as a write, such as appending a note or emitting an event another system listens to.
  • Stale reads. A step reads case state, spends time reasoning, and writes a decision based on a world that has since changed, because a person touched the claim in the meantime. Version the row and refuse the write on a version mismatch. Losing the work and re-running the step is cheap. Overwriting a human decision with a stale machine one is not.
  • Absent evidence reported as a finding. The evidence agent looks for a repair quote, finds nothing, and reports nothing found in a register that reads exactly like a finding. Downstream, absent and not looked for become indistinguishable. The envelope has to carry both, separately, and the correspondence agent has to be unable to write about a document class that was never searched.
  • Supervisor loops. The supervisor returns a step, the step completes without materially changing case state, and the supervisor returns the same step again. The transition cap catches it. Treating the cap as an incident rather than as a retry budget is what turns a silent cost leak into a signal.
  • Untrusted claim text. The claim description is written by whoever lodged the claim, which makes it attacker-controlled content arriving at a system that holds tools. It is delimited as data at every boundary, it never reaches the supervisor as anything resembling an instruction, and the tools it can influence are read-scoped by their execution role rather than by their prompt.
  • Corpus drift. Policy wording changes. A cover assessment that was correct against the version in force is wrong against the version retrieved. The wording corpus is versioned, retrieval is pinned to the version in force at the date of loss, and the version is recorded in the finding. This is the failure with the longest lag between cause and discovery, which is why it gets designed for rather than monitored for.

Settle these first

Six decisions, in the order they actually have to be made.

  • Write the reversibility list before you write a tool. For every action the system could take, record whether it is reversible, visible and outward-facing, and settle it with the people who carry the consequence rather than the people writing the code. Every autonomy question later resolves against that list.
  • Draw agent boundaries by permission and data reach not by role names. If two steps need the same tools and the same data, they are one agent.
  • Put the topology in something you can read. If the answer to what happens next lives in a model context rather than in a definition file, you cannot review it, diff it or replay it.
  • Bind case identity in the execution context and never accept it as a model-supplied argument.
  • Keep prose out of the contract between agents. One quarantined rationale field for humans, structured values for everything else.
  • Make it replayable before you make it clever. Idempotency keys, versioned rows and a recorded transition history are not the polish at the end. They are the thing that makes every later change safe to attempt.

The framework language calls this the top of a maturity ladder, and that framing invites the wrong conclusion. Agentic is not a capability level you reach by using better models. It is a permission model. The interesting question a multi-agent build asks is never whether the agents are clever enough. It is which of them is allowed to be wrong, about what, and who finds out.

References and acknowledgements

Amazon States Language specification, the grammar the workflow definition above is written in.

AWS Step Functions developer guide, for execution history, retry and catch semantics, and the task token callback pattern used at the release step.

Amazon Bedrock agents documentation, for action groups and the per-agent execution role model.

OWASP Top 10 for Large Language Model Applications, the reference we hold tool-holding systems against, and the source of the untrusted input and excessive agency framings used above.

ISO/IEC 42001, the AI management system standard, for the obligation to record decisions and oversight rather than assert them.

With thanks to the claims and technology teams in general insurance who set the operating constraints this design had to satisfy, and to the risk owners who insisted on the release gate before anyone had built anything that needed one.

Ready to shipEnterprise AI?

Get the Executive Guide