---
sidebar_position: 6
title: Sub-graphs (parent → child)
---

# Sub-graphs

A **sub-graph node** runs another deployed agent as a child of the current one. Use it when a step is large enough to deserve its own workflow definition — its own state schema, its own version, its own activity-tab history — but you want a parent to dispatch it as part of a larger flow.

The shape is one extra field on the existing node config:

```js
g.addNode('audit', {
  workflow: 'deep-audit',   // ← name of another agent in this project
});
```

That's it. No new imports, no UUID, no separate class. The engine recognizes `workflow:` and turns this node into a sub-graph dispatcher.

## When to use a sub-graph

| Scenario | Sub-graph? |
|---|---|
| Two parents need the same multi-node flow | ✅ Yes — define it once as a child, reference by name |
| One step needs different state schema than the rest | ✅ Yes — each agent has its own schema |
| You want per-step activity-tab history + replay | ✅ Yes — each child run gets its own row |
| Step is a single LLM call | ❌ No — just add a regular node |
| Step has its own retry policy | Either works, but a sub-graph gives independent control |

## Sync vs async

`async:` flips the dispatch mode:

```js
g.addNode('audit',  { workflow: 'deep-audit' });                  // sync (default)
g.addNode('notify', { workflow: 'slack-notifier', async: true }); // fire-and-forget
```

| Mode | Behavior | Returns to parent | Use for |
|---|---|---|---|
| **sync** (default) | Parent blocks, polls child until terminal status, merges result into parent state | the extracted value (see `output:` below) | Steps where downstream nodes depend on the child's result |
| **async** (`async: true`) | Parent dispatches the child and continues immediately. No polling. | a dispatch handle `{ jobId, status, workflow }` | Fan-out, notifications, side-effect work the parent shouldn't wait for |

Quota: every sub-graph run counts as a separate execution against the account's monthly cap (parent + 3 children = 4 executions).

## Full option surface

```js
g.addNode('audit', {
  // ─── Required ─────────────────────────────────────────────────────
  workflow: 'deep-audit',          // resolved by name within this project

  // ─── Mode (default sync) ──────────────────────────────────────────
  async: false,                    // false = block + merge, true = fire-forget

  // ─── State plumbing ───────────────────────────────────────────────
  input: (state) => ({             // shape parent state → child input
    ticketId: state.ticketId,
  }),                              // OR a plain object  OR omit (child gets {})

  output: 'audit.score',           // dot-path on child finalState
                                   // OR (childState) => ({...})  function form
                                   // OR omit → entire child finalState

  // ─── Sync tunings (ignored when async: true) ──────────────────────
  timeoutMs: 5 * 60 * 1000,        // throw after this long (default 10min)
  pollIntervalMs: 2000,            // status-check frequency (default 2s)

  // ─── Cross-cutting concerns ───────────────────────────────────────
  retries: 3,                      // engine retries whole dispatch on transient failure
  onComplete: (state, result) => result,

  // ─── Advanced ─────────────────────────────────────────────────────
  conversationId: 'inherit',       // 'inherit' (default) | 'new' | (state) => string
});
```

## How state flows

Each agent has its own state schema — they're independent. The parent must transform its state into the child's input shape, and (optionally) extract whatever it needs back out.

### A complete example — `parent-orchestrator` calls `child-doubler`

```js
// child-doubler — takes a number, returns it doubled.
class ChildDoublerAgent extends WorkflowAgent {
  buildGraph() {
    const g = new WorkflowGraph();
    g.setStateSchema(z.object({
      value:  z.number(),
      double: z.object({ doubled: z.number() }).optional(),
    }));
    g.addNode('double', {
      _isCustomCode: true,
      outputSchema: z.object({ doubled: z.number() }),
      execute: async (ctx) => ({ doubled: ctx.state.getAll().value * 2 }),
    });
    g.setEntryPoint('double');
    g.addEdge('double', 'END');
    return g;
  }
}

// parent-orchestrator — picks a number, calls child-doubler, reports.
class ParentOrchestratorAgent extends WorkflowAgent {
  buildGraph() {
    const g = new WorkflowGraph();
    g.setStateSchema(z.object({
      seed:         z.number(),
      pick_number:  z.object({ value: z.number(), label: z.string() }).optional(),
      call_doubler: z.number().optional(),       // ← child's result lands here
      report:       z.object({ summary: z.string() }).optional(),
    }));

    g.addNode('pick_number', pickNumberNode);

    g.addNode('call_doubler', {
      workflow: 'child-doubler',
      input:  (state) => ({ value: state.pick_number.value }),
      output: 'double.doubled',                  // dot-path through child's node name
    });

    g.addNode('report', reportNode);             // reads state.call_doubler

    g.setEntryPoint('pick_number');
    g.addEdge('pick_number', 'call_doubler');
    g.addEdge('call_doubler', 'report');
    g.addEdge('report', 'END');
    return g;
  }
}
```

Triggering the parent with `{ seed: 21 }`:

| Step | What happens | State after |
|---|---|---|
| 1 | `pick_number` runs | `{ seed: 21, pick_number: { value: 21, label: '…' } }` |
| 2 | `call_doubler.input(state)` fires | returns `{ value: 21 }` |
| 3 | Server validates `{ value: 21 }` against child's `stateSchema` | passes |
| 4 | Child runs in its own Fargate task. Final state: `{ value: 21, double: { doubled: 42 } }` | (parent waiting) |
| 5 | Engine extracts `output: 'double.doubled'` → `42` | `state.call_doubler = 42` |
| 6 | `report` runs, reads `state.call_doubler` | `state.report.summary = '…42…'` |

### Why `output: 'double.doubled'` and not `'doubled'`?

Each node's output is stored at `state[nodeName]` in its own graph. So when the child's `double` node returns `{ doubled: 42 }`, that lands at `childState.double.doubled` — `doubled` is *nested under the node name*, not promoted to the top level.

If you want multiple fields, use the function form:

```js
output: (childState) => ({
  doubled:  childState.double.doubled,
  echoed:   childState.value,
  isDouble: childState.double.doubled === childState.value * 2,
}),
// → state.audit = { doubled: 42, echoed: 21, isDouble: true }
```

Or omit `output:` and the entire `childState` lands at `state[nodeName]` — useful when you don't know yet which fields you'll need.

## Schema validation at the boundary

The server runs the same input gate sub-graph triggers hit as user-initiated ones. If the parent's `input:` callback returns a value that doesn't satisfy the child's `stateSchema`, the trigger 400s **before** any Fargate spawn — no wasted compute. The parent's sub-graph node throws a typed error with the missing fields listed.

## Errors

Sub-graph failures throw with a `code` field so you can branch:

| `err.code` | Meaning | Useful properties |
|---|---|---|
| `SUBGRAPH_INVALID_INPUT` | Parent's `input:` produced data that violates the child's stateSchema | `err.missing[]`, `err.validationErrors` |
| `SUBGRAPH_QUOTA_EXCEEDED` | Account is over its execution cap; child can't dispatch | `err.quotaInfo` |
| `SUBGRAPH_TRIGGER_FAILED` | Any other HTTP failure from the trigger endpoint | `err.status` |

Sync-mode terminal failures (child completed in `failed` / `canceled` / `timeout`):

```js
err.subgraphJobId      // child's executionId — look up in activity tab
err.subgraphStatus     // 'failed' | 'canceled' | 'timeout'
```

`retries:` on the sub-graph node re-runs the whole dispatch (trigger + poll) on transient failures, same semantics as a regular node retry.

## What's deployed vs what you write

You only ever reference agents by **name**. The cloud handles the UUID resolution.

| Stage | What you write | What the backend does |
|---|---|---|
| `zibby agent deploy child-doubler` | nothing about UUIDs | mints UUID, stores `(projectId, workflowType='child-doubler', uuid='…')` |
| `subgraph('child-doubler')` in parent code | just the name | stored as a string in the parent's graph definition |
| `zibby agent deploy parent-orchestrator` | nothing | looks up `'child-doubler'` → UUID, snapshots the dependency |
| Parent runs in Fargate → hits sub-graph node | nothing | POSTs to `/workflows/<uuid>/trigger` with `parentExecutionId` |

Names are unique per project (DDB primary key enforces this), so `subgraph('child-doubler')` resolves unambiguously within the parent's project.

## Activity-tab tree-view

Each child execution row carries `parentExecutionId` pointing at the parent. The activity tab uses this to render parent runs as collapsible groups — expand to see the chain of children.

| Row | `parentExecutionId` | Type |
|---|---|---|
| Parent (orchestrator) | `null` | top-level (user-triggered) |
| Child (doubler) | `<parent's executionId>` | dispatched as sub-graph |

## Local development

Sub-graph dispatch needs the `PROGRESS_API_URL` env var (the public API base). That's set automatically on Fargate runs. For local dev, you have two options:

1. **Deploy both agents to cloud, then trigger the parent.** The cloud path always works.
2. **Mock the trigger + status endpoints locally.** See [`workflows/parent-orchestrator/mock-server.mjs`](https://github.com/ZibbyDev/agent-workflow/tree/main/examples) in the agent-workflow repo for a 90-line example that simulates the dispatch + poll loop.

In-process sub-graph execution (running the child in the parent's Node process directly, no HTTP) is **not supported** — we picked consistency between local and cloud over the 10s spawn-time savings.

## Cross-project sub-graphs

`workflow: 'name'` resolves within the parent's own project. To call another project's agent, pass an explicit project ID:

```js
g.addNode('audit', {
  workflow: 'shared-audit',
  project:  'b6219c3a-…',   // explicit cross-project reference
});
```

The caller must have access to the destination project (same account, or invited). Cross-**account** sub-graphs are not in v1.
