# Writing Workspace Jobs

> Author FSM workspace jobs. Use when creating, editing, or debugging jobs, signals, or FSM workflows in workspace.yml.

- Skill: `friday-platform/writing-workspace-jobs` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add friday-platform/writing-workspace-jobs`
- Raw SKILL.md: https://api.skillmd.com/api/skills/friday-platform/writing-workspace-jobs/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: friday-platform (https://skillmd.com/u/friday-platform)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/friday-platform/writing-workspace-jobs

---


# Writing workspace jobs

Validate cleanly. Run reliably. Load before authoring any `fsm:` job.

**Load `writing-workspace-signals` alongside this skill — always, not
conditionally.** A job is wired to a trigger signal; authoring one means
authoring the other. Signal `provider` names (`schedule`, `http`, `fs-watch`,
`slack`, …) and their `config` shapes live in that skill. Guessing them —
inventing a `cron` provider, passing unrecognized `config` keys — is the most
common first-failure when building a workspace, and this skill does not
duplicate those shapes.

## Checklist

- [ ] Job uses `fsm:` — `execution:` silently skipped by runtime
- [ ] `initial` state exists in `states`, handles trigger signal name in `on`
- [ ] Every non-final state has ≥1 outgoing transition
- [ ] ≥1 state has `type: final`
- [ ] All `agentId` values exist in `agents`
- [ ] MCP tools use `serverId/toolName` format
- [ ] `emit` event names match `on` transition keys exactly
- [ ] Multi-step jobs chain via `outputTo` → `inputFrom` (array form when step needs >1 prior output)
- [ ] Every LLM-backed `outputTo` action has a `complete` output contract (`outputType` schema args, or `{ response }` when untyped)

## Trigger contract (common silent failure)

Signal fires → runtime finds matching job → resets FSM to `initial` → sends `{ type: <signal>, data: <payload> }` → engine checks `currentState.on[<signal>]`.

**No handler match = silently ignored.**

Rule: `initial` state's `on` key must exactly match trigger signal name.

Wrong — `triage-now` ignored because `triage` handles only `done`:

```yaml
fsm:
  initial: triage
  states:
    triage:
      entry:
        - type: agent
          agentId: triage-agent
      on:
        done: { target: done }
    done:
      type: final
```

Right — `idle` handles trigger, routes to work state:

```yaml
fsm:
  initial: idle
  states:
    idle:
      on:
        triage-now: { target: run }
    run:
      entry:
        - type: agent
          agentId: triage-agent
      type: final
```

## MCP tool naming

`list_mcp_tools({ serverId: "google-gmail" })` returns bare names:

```json
[{ "name": "search_gmail_messages" }]
```

Two places declare tools, with different naming rules:

- **`agents.*.config.tools`** (`type: llm` agent declared at workspace top
  level): use `serverId/toolName` for workspace MCP tools. Validator
  accepts prefixed tools for declared servers even when server offline.
- **FSM action `entry[].tools`** (per-action allowlist on `type: llm` /
  `type: agent`): same rule for workspace MCP tools — `serverId/toolName`.
  **Atlas-platform built-ins (`fs_glob`, `fs_read_file`, `fs_write_file`,
  `save_memory_entry`, `list_memory_entries`, `create_artifact`, `parse_artifact`,
  `display_artifact`, `request_tool_access`, etc.) use bare names** —
  atlas-platform is auto-injected, no prefix needed.

| `list_mcp_tools` returns | Write in agent or action `tools` |
|---|---|
| `"search_gmail_messages"` (workspace MCP) | `"google-gmail/search_gmail_messages"` |
| `"get_gmail_message_content"` (workspace MCP) | `"google-gmail/get_gmail_message_content"` |
| Atlas-platform built-in | `"save_memory_entry"`, `"fs_glob"`, etc. — no prefix |

## Minimal valid job

Copy, rename, publish. Every field required.

```yaml
jobs:
  my-job:
    description: "One-line description"
    triggers:
      - signal: my-signal
    fsm:
      initial: idle
      states:
        idle:
          on:
            my-signal: { target: run }
        run:
          entry:
            - type: agent
              agentId: my-agent
          type: final
```

Required pieces:
- `jobs.<job-id>` — kebab-case, referenced by signal `triggers`
- `triggers` — at least one `{ signal: <name> }`
- `fsm.initial` — key in `fsm.states`
- `fsm.states` — every non-final state needs `on` with outgoing transitions
- `type: final` on terminal states

## Multi-step pipeline

Chain agents with `outputTo` → `inputFrom`. Each step emits `DONE`, transitions forward.

```yaml
fsm:
  initial: idle
  states:
    idle:
      on:
        go: { target: step-a }
    step-a:
      entry:
        - type: agent
          agentId: agent-a
          outputTo: step-a-result
        - type: emit
          event: DONE
      on:
        DONE: { target: step-b }
    step-b:
      entry:
        - type: agent
          agentId: agent-b
          inputFrom: step-a-result
        - type: emit
          event: DONE
      on:
        DONE: { target: completed }
    completed:
      type: final
```

Key fields:
- `outputTo: <doc-id>` — saves agent result as named document
- `inputFrom: <doc-id>` — feeds prior step output into next agent task
- `emit` with `event: DONE` — signals engine to transition

### Python user-agent `inputFrom` consumption

When an upstream `outputTo` is bulky, the runtime may persist it as an artifact
and pass downstream actions only a compact summary + `artifactRefs` shape. Do not
fix this by asking the producer to inline the full data; that breaks refs-over-
data and pollutes supervisor context.

For Python user agents, consume compact refs through `ctx.input`:

```python
payload = ctx.input.artifact_json("emails-result")
emails = payload.get("emails", [])
return ok({"count": len(emails)})
```

Use `ctx.input.get("emails-result")` for the compact summary, and
`ctx.input.artifact_refs("emails-result")` when you need to inspect the refs.
Treat `parse_input(prompt)` as a fallback for simple one-shot prompt JSON, not as
the handoff mechanism for multi-step `outputTo`/`inputFrom` pipelines.

## Returning data to the caller

`outputTo` does double duty. Inside a multi-step FSM it chains a step's output into the next step's `inputFrom` (above). It is **also the only mechanism for surfacing data back to whoever fired the signal** — the synchronous signal response (`POST /api/workspaces/:id/signals/:id`) returns docs collected from `engine.documents` as `output: [{ id, type, data }]`. No `outputTo` → no doc → caller sees `output: []` even on `status: completed`.

There is no separate `outputs:` block on a job that pipes the FSM result to the caller. The schema has an `outputs:` field but it's a memory-write declaration (`{ memory, entryKind }`) — unrelated.

**Mechanical output contract:** every LLM-backed action with `outputTo` must finish by calling the runtime-injected `complete` tool. If the action declares `outputType`, `complete` args must match that document schema. If it does not, emit the full final text as `complete({ response: "..." })`. Do not rely on prose after the last MCP/tool call; if `complete` is not called, the session fails instead of persisting an empty/stub document. The full agent ↔ FSM-action contract — invocation kinds, `inputFrom` resolution, what the runtime auto-injects per kind — is in `@friday/agent-action-handshake`. Load it when authoring agents that will be invoked from FSM actions.

**Single-step jobs that need to return data:** put `outputTo` on the entry action even when the state is `type: final`. Entry actions still execute on entering a final state, and the doc is captured before session completion.

```yaml
fsm:
  initial: idle
  states:
    idle:
      on: { run-search: { target: search } }
    search:
      entry:
        - type: agent
          agentId: search-agent
          outputTo: search-result          # required for output to reach caller
          prompt: "{{inputs.query}}"
      type: final                           # entry runs THEN state finalizes
```

Caller sees (SSE `job-complete` event, modern shape):

```json
{
  "status": "completed",
  "sessionId": "...",
  "artifactIds": ["art-abc", "art-def"],
  "summary": "Searched index; found 12 matches. Top result archived.",
  "output": [
    { "id": "search-result", "type": "AgentResult", "data": { ... } }
  ]
}
```

`artifactIds` are JetStream-persisted refs to each non-plumbing FSM document the job produced. `summary` is a short human-readable digest synthesized from `aiSummary` (preferred) → terminal-state action's `summary` field → structural digest of `outputTo` data (fallback). See "Writing artifact summaries the supervisor can answer from" below for guidance on making the summary self-sufficient.

**The supervisor (workspace-chat) prefers `artifactIds` + `summary` over `output` when both are present** — the LLM-visible tool result drops `output` entirely so the supervisor's next-turn input doesn't ingest the full Document[]. If the LLM needs an artifact's contents, it calls `parse_artifact(<id>)`.

You can declare a per-action `summary` on `type: llm` and `type: agent` actions to override the synthesized digest with author-controlled prose:

```yaml
- type: agent
  agentId: search-agent
  outputTo: search-result
  summary: "Index search across the knowledge base; returns top-scored matches."
```

**Verify after publish.** Fire the signal once with a fixture payload (`POST /api/workspaces/:id/signals/:signalId` with a JSON body) and confirm `artifactIds` is non-empty before declaring the workspace done. Empty `artifactIds` (or empty legacy `output`) means the data never reached the caller — almost always a missing `outputTo`.

## Multi-input steps

Step needs multiple prior outputs? Pass `inputFrom` as array. Engine concatenates each doc labeled by id (`<id>:\n<data>`) with blank lines between. Agent receives combined text as task.

```yaml
fetch-emails:
  entry:
    - type: agent
      agentId: gmail-agent
      outputTo: emails-result
    - type: emit
      event: DONE
  on:
    DONE: { target: fetch-calendar }

fetch-calendar:
  entry:
    - type: agent
      agentId: gcal-agent
      outputTo: calendar-result
    - type: emit
      event: DONE
  on:
    DONE: { target: summarize }

summarize:
  entry:
    - type: agent
      agentId: summarizer-agent
      inputFrom: [emails-result, calendar-result]
      outputTo: brief-result
      prompt: |
        Produce daily brief from emails and calendar data below.
        Group by source. Keep scannable.
    - type: emit
      event: DONE
```

Agent receives:
```
emails-result: <emails data>

calendar-result: <calendar data>
```

Use array `inputFrom` for "combine N results".

## Signal payload threading

Signal-payload fields are auto-seeded into `prepareResult.config` and reachable as `{{inputs.<field>}}` in **every** step's `prompt` — not just the first. This holds even when a step also declares `inputFrom`: the engine merges chained-doc keys on top of the carried-over signal payload, so end-to-end values like a recipient email or a target id keep resolving through the whole pipeline.

Use this for values that the entire job needs and the caller supplies. Don't try to thread them through `inputFrom` chains by re-emitting them in each agent's output — agents drop fields, paraphrase JSON, or just forget. The signal payload is the only durable carrier.

```yaml
signals:
  send-report:
    schema:
      type: object
      properties:
        report_id:    { type: string }
        notify_email: { type: string }
      required: [report_id, notify_email]

jobs:
  send-report:
    fsm:
      initial: idle
      states:
        idle: { on: { send-report: { target: build } } }
        build:
          entry:
            - type: agent
              agentId: report-builder
              prompt: "Build report {{inputs.report_id}}"
              outputTo: report
            - type: emit
              event: DONE
          on: { DONE: { target: send } }
        send:
          entry:
            - type: agent
              agentId: emailer
              inputFrom: report                   # chained doc
              prompt: "Send to {{inputs.notify_email}}"   # still resolves
          type: final
```

Collisions: if a chained doc id matches a signal-payload key, the chained data wins. Pick distinct names.

## Crossing session boundaries

`inputFrom` only reads documents in the current FSM session. Every signal fires a fresh session with an empty doc store — a job triggered by signal B cannot `inputFrom` a document produced by a prior session of signal A.

Pass the data on the signal payload. Declare the fields the next job needs in the signal `schema`; the caller (chat agent, webhook) includes them when firing the signal. The runtime auto-seeds `prepareResult.config` from the payload, so action prompts can reference `{{inputs.<field>}}` directly — no `inputFrom` wiring.

```yaml
signals:
  apply-actions:
    provider: http
    config: { path: /apply-actions }
    schema:
      type: object
      properties:
        actions: { type: string }
        items:   { type: string }
      required: [actions, items]

jobs:
  apply-actions:
    triggers: [{ signal: apply-actions }]
    fsm:
      initial: idle
      states:
        idle:
          on: { apply-actions: { target: act } }
        act:
          entry:
            - type: agent
              agentId: action-agent
              prompt: |
                Apply {{inputs.actions}} to {{inputs.items}}.
          type: final
```

`type: user` Python agents that call `parse_input(prompt, MyDataclass)` see the wrapped shape `{ "config": { ... } }` rather than the fields at the top level. Either keep `{{inputs.<field>}}` interpolation in the action prompt as above, or unwrap `config` in the agent before typing — see `writing-friday-python-agents` → `references/input-parsing.md` for the snippet.

For large data **inside the same FSM session**, prefer `outputTo` → `inputFrom`; the runtime stores bulky documents as artifact refs and hydrates those refs before the next action runs. For data that must cross session boundaries, persist with the artifact + memory pattern — producer calls `create_artifact` then `save_memory_entry` to record the id; a later session reads the id from memory and calls `parse_artifact`. See the `writing-to-memory` skill.

## Single-action FSMs post-supervisor-flip

Default to **one LLM action per job** when the work is "fetch some
context, then emit a result." The two-state `fetch → format` shape
(action A pulls structured data into `outputTo: foo-result`; action B
takes `inputFrom: foo-result` and reformats it as the user-visible
markdown) is a pre-supervisor-flip pattern — it doubled the LLM calls
for no behavioral gain.

Post-flip the supervisor (workspace-chat) is the consumer. It already
sees `{ artifactIds, summary }` from the terminal action and renders
the artifact body verbatim to the user. The second pass is wasted
latency + tokens.

### When to collapse

Collapse two states into one when **all** are true:

- Both actions run the same kind of work (LLM with tools).
- The first action's only consumer is the second action — no other
  branch reads `outputTo: foo-result`.
- The second action's only job is reformat / re-summarize the first's
  output. No new tool calls beyond cosmetic ones.
- The terminal output is what the user (via the supervisor) consumes.

### When NOT to collapse

Keep the two-state shape when **any** is true:

- A genuine fan-in: the second action takes `inputFrom: [a-result, b-result]`
  combining outputs from parallel branches.
- The first action's output is also persisted for cross-session reuse
  (a different job's signal payload references it later).
- The two actions need independent budgets, allowlists, or models —
  e.g. cheap-Haiku fetcher feeding paranoid-Opus drafter.
- The first action's tools are mutating and you want a separate
  read-only formatter to render the result safely.

### Worked example

Before — two states, two LLM calls (~24s of redundant work on a real run):

```yaml
fsm:
  initial: idle
  states:
    idle:
      on:
        review-inbox: { target: fetch }
    fetch:
      entry:
        - type: agent
          agentId: inbox-fetcher       # gmail search + batch get
          outputTo: emails-result
        - type: emit
          event: DONE
      on:
        DONE: { target: review }
    review:
      entry:
        - type: agent
          agentId: inbox-reviewer      # reformat JSON as markdown
          inputFrom: emails-result
          outputTo: review-result
      type: final
```

After — one state, one LLM call. The terminal action both fetches
AND emits the user-visible markdown:

```yaml
fsm:
  initial: idle
  states:
    idle:
      on:
        review-inbox: { target: review }
    review:
      entry:
        - type: agent
          agentId: inbox-reviewer      # fetch + format in one prompt
          outputTo: review-result
      type: final
```

The merged agent's prompt is two short sections: "Step 1 — fetch"
(declares the tools and call shape) and "Step 2 — emit" (declares the
markdown shape and "no prose before or after"). Tools list is the
union of what both agents needed; declared output is what the user
sees.

### Verify after collapse

Re-fire the signal once. Confirm:

- `output[0].id` is unchanged (still `review-result`); supervisor
  consumers keep working.
- `summary` reads sensibly without `parse_artifact` — the auto-derived
  digest from the markdown is the supervisor's first-pass view.

If the auto-derived `summary` is too thin, set `summary:` on the
action explicitly rather than reintroducing a second formatter step.

## Conditional branching

**Currently agent-level, not FSM-level.** Guards and code-action helpers were
removed from the FSM engine; transitions take the first matching event without
predicates. To branch on data, decide and act inside a single agent, or chain
agents that each handle their own branch.

The simplest pattern: a single LLM agent that reads inputs, decides the path,
and acts in one step.

```yaml
fsm:
  initial: idle
  states:
    idle:
      on:
        go: { target: route-and-act }
    route-and-act:
      entry:
        - type: agent
          agentId: router-agent
          prompt: |
            Decide the path (A, B, or default) from the inputs and execute it
            yourself. Save the chosen path + outcome to memory.
          outputTo: decision
      type: final
```

For coarser routing where you really need separate states (e.g. each path
runs a *different* SDK agent), wire the router agent to write its choice to a
memory store, then have downstream supervisors read that store and dispatch.
This is the same pattern chat uses to coordinate multi-step work without FSM
guards.

### LLM router → Python user agent dispatcher

When the routing decision is a judgment call (LLM) but the per-branch work is
deterministic (fixed transforms, schema-constrained tool calls), pair an LLM
router with a `type: user` Python SDK agent. The LLM emits a structured
decision document; the Python agent reads it via `inputFrom` and dispatches
mechanically.

Workspace shape:

```yaml
agents:
  router:
    type: llm
    config:
      provider: anthropic
      model: claude-sonnet-4-6
      prompt: |
        Classify the inbox item by intent: "billing", "support", or "other".
        Emit a routing-decision doc.

  dispatcher:
    type: user
    agent: dispatcher-py    # registered Python SDK id
    # No tools array — type: user agents resolve tools via ctx.tools.call

jobs:
  triage-and-dispatch:
    triggers:
      - signal: triage-now
    fsm:
      initial: idle
      documentTypes:
        routing-decision:
          type: object
          properties:
            path:    { type: string, enum: ["billing", "support", "other"] }
            item_id: { type: string }
          required: [path, item_id]
      states:
        idle:
          on: { triage-now: { target: route } }
        route:
          entry:
            - type: agent
              agentId: router
              outputTo: routing-decision
              outputType: routing-decision
            - type: emit
              event: DONE
          on: { DONE: { target: dispatch } }
        dispatch:
          entry:
            - type: agent
              agentId: dispatcher
              inputFrom: routing-decision
          type: final
```

`agent.py` (registered separately via `POST /api/agents/register`):

```python
from friday_agent_sdk import agent, Context
from dataclasses import dataclass

@dataclass
class RoutingDecision:
    path: str
    item_id: str

@agent(id="dispatcher-py", version="0.1.0")
async def dispatcher(ctx: Context, prompt: str) -> str:
    # parse_input unwraps the FSM's wrapped {config: ...} shape; see the
    # writing-friday-python-agents skill for details.
    decision = ctx.parse_input(prompt, RoutingDecision)

    if decision.path == "billing":
        # Mechanical dispatch: known tool, known shape.
        result = await ctx.tools.call(
            "google-gmail/batch_modify_gmail_message_labels",
            {"message_ids": [decision.item_id], "add_label_ids": ["LABEL_BILLING"]},
        )
        return f"Routed {decision.item_id} to billing"

    if decision.path == "support":
        result = await ctx.tools.call(
            "linear/create_issue",
            {"title": "Support ticket", "external_id": decision.item_id},
        )
        return f"Routed {decision.item_id} to support"

    # default
    return f"No action for {decision.item_id}"
```

When this is the right shape:
- The LLM's only job is the classification (one decision per item).
- The dispatcher's branches are mechanical (no `ctx.llm.generate` calls).
- The Python agent is registered ahead of time — see the
  `writing-friday-python-agents` skill for the registration two-step
  (`POST /api/agents/register` then `upsert_agent`).

When NOT this shape — keep the work in one LLM action:
- Each branch's body itself needs LLM judgment (drafting different
  responses per category, scoring tradeoffs). Hybrid is overkill — a
  single LLM action with the full tool surface is simpler and the
  per-action `tools:` allowlist still locks it down.

> Note: predicate-on-transition is **not** on the roadmap. Agent-level
> branching is the intended forward path — the LLM action is more
> expressive than any guard expression we'd ship, and the supervisor-flip
> world means a routing agent can read context and dispatch via
> `delegate` without holding the items in its own buffer.

## Per-action tool + skill allowlist

`type: llm` and `type: agent` actions take optional `tools: [...]` and
`skills: [...]` arrays — both runtime-enforced. They narrow the catalog
**within** a single action, on top of any per-agent or workspace-level
filtering.

```yaml
- type: llm
  provider: anthropic
  model: claude-sonnet-4-6
  tools:
    - fs_glob               # atlas-platform built-in (bare)
    - google-gmail/search_gmail_messages  # workspace MCP (prefixed)
  skills:
    - composing-emails       # only this skill loadable here
  outputTo: triage
  prompt: |
    ...
```

Semantics:
- `tools: []` (empty array) — no MCP server tools available, but
  platform tools still inject (full set in
  `packages/agent-sdk/src/platform-tools.ts`: memory, artifacts, fs,
  shell/data, state, HITL, `complete` when `outputTo` set,
  `load_skill`, `delegate`). Useful for a pure-reasoning action that
  shouldn't reach external integrations.
- `tools: [...]` (populated) — exactly those tools, plus the built-ins.
- `tools` absent — inherits the agent/workspace tool surface, which may
  itself be permissive.
- `skills: []` — `load_skill` registered but resolves to "no skills
  available." Locks the action down.
- `skills: [...]` — whitelist within the job's resolved skill set.
- `skills` absent — full job-level skill visibility.

These fields are load-bearing. If a fetcher action lists no send tool but the
prompt is ambiguous, the agent genuinely cannot send.

## Auto-injected built-ins for FSM `type: llm` actions

You do **not** need to declare these in the action's `tools:` array;
they're **always** available regardless of what you put in `tools:`.
The `tools:` field narrows MCP-server tools (workspace-declared
external integrations); platform tools are ambient and not subject to
that narrowing.

**Memory** — `save_memory_entry`, `list_memory_entries`, `delete_memory_entry`.
**Artifacts** — `create_artifact`, `get_artifact`, `artifacts_update`,
`artifacts_delete`, `artifacts_get_by_chat`, `parse_artifact`,
`display_artifact`.
**Filesystem** — `fs_read_file`, `fs_write_file`, `fs_list_files`,
`fs_glob`, `fs_grep`. **Working directory: relative paths resolve
to the workspace dir** (`~/.atlas/workspaces/<workspaceId>/`).
Absolute paths still work but pin the file outside the workspace —
prefer relative for portability and so `git`-style backups capture
your output. **For files OUTSIDE the workspace (a repo cloned to
`~/somewhere`) use a `~/...` or `$HOME/...` path — `fs_*` expands `~`,
`$HOME`, and `$USER`.** Do NOT hand-write a literal absolute home dir
(`/Users/<name>/…`): the model guesses the username wrong (a real
failure mode — an agent looped on `fs_read_file` with invented
`/Users/<name>/…` paths until it ran out of steps). For needs the
`fs_*` tools don't cover (pipes, multi-step shell) `bash` runs under
`/bin/bash -c` with the daemon's environment and expands `~` / `$HOME`
the same way.
**Shell + data** — `bash`, `csv`, `webfetch`. `bash` is workspace-CWD-
scoped (same default as `fs_write_file`).
**State** — `state_append`, `state_filter`, `state_lookup`.
**Permissions** — `request_tool_access` (see below).
**Skills** — `load_skill` (loads a versioned skill body into context).
**Delegation** — `delegate` (spawn a sub-agent — see "Delegating to a
sub-agent from an FSM action" below).

Recent memory entries auto-prepend to the action's prompt. Recent
session artifacts auto-inject as `<retrieved_content>` envelopes. No
boilerplate.

If you declare `tools: [...]`, the built-ins still work — the
allowlist narrows the **MCP-server** catalog only. To genuinely lock
an action down to "memory only," you can't (today) — the platform
tool surface is fixed. Long-term: a `platform_tools: [...]` opt-in would make
this narrower.

**You cannot exclude a platform built-in via `tools:`.** Listing or
omitting `fs_read_file` / `bash` / `save_memory_entry` in `tools:`
changes nothing — they are ambient. Switching a named agent to an
inline `type: llm` action does **not** change this; both honor the
same MCP-only narrowing. If an action keeps misusing a platform tool,
the lever is the prompt, not the allowlist.

### Output contract: `complete` is the only durable emission for `outputTo`

When an LLM-backed action has `outputTo`, the runtime injects a
`complete` tool and treats that call as the action's durable output.
This is true even when `outputType` is omitted:

- With `outputType: <DocumentType>`, call `complete` with fields that
  match the declared document schema.
- Without `outputType`, call `complete({ response: "<full final text>" })`.
- If the action also declares tools, the runtime uses `toolChoice: "required"`
  until `complete` appears, so the model cannot stop with prose after a
  search/write/memory call. For `outputTo` actions that must call an ambient
  built-in first, still list that built-in in `tools:`; built-ins remain
  available either way, but the declaration tells the runtime not to force the
  first tool call to be `complete`.

Do **not** end an `outputTo` action on `fs_write_file`,
`save_memory_entry`, or any other non-`complete` tool. Those calls may be useful
side effects, but they are not the output document. If `complete` is missing,
the session fails with `LLM action with outputTo '<id>' did not call complete`
rather than persisting an empty `{ response: "" }` stub.

Preferred pattern for reports: call any tools needed, write auxiliary files if
useful, then `complete` with a compact structured/report payload containing the
report path/ref, counts, and summary. The supervisor sees the compact
`{ artifactIds, summary }` job result first and only calls `parse_artifact` when
it needs the full body.

## FSM action validation

There is no `validate:` field. Output emission is captured via `complete` when `outputTo` is set — see `@friday/agent-action-handshake` for the contract.

## Delegating to a sub-agent from an FSM action

`type: llm` actions can spawn a sub-agent via the `delegate` tool —
opt in by listing it:

```yaml
- type: llm
  tools:
    - delegate
  prompt: |
    For each item in {{inputs.items}}, delegate the per-item judgment
    to a sub-agent with the gmail tools it needs. Collect the answers
    and emit a structured summary.
```

The child runs in an isolated context — its tool calls don't pollute
the parent action's message buffer. The parent sees only the child's
final `answer` (and the chunk stream for live UI). Bounded by:

- **`max_depth`** — default 1; child cannot itself call `delegate`.
- **`max_steps_per_call`**, **`max_output_tokens`**, **`max_input_tokens`**,
  **`max_wall_time_ms`** — workspace-level (`delegation:` block) +
  per-job override (`jobs.<name>.delegation:`). Budget exhaustion
  returns `{ ok: false, reason: "budget_exhausted: <which>" }` — the
  parent can fail-step or route around.

**When to delegate from an FSM action vs. compose with `inputFrom`:**
- Per-item expansion that would explode the parent's context →
  `delegate`. The child holds the items; the parent gets a digest.
- Sequential pipelines (fetch → format → emit) → chain states with
  `outputTo` → `inputFrom`. No delegation needed.

## Parallelizing per-item work via delegate fan-out

Per-item workloads (triage N emails, classify N tickets, score N
candidates) that today run sequentially in one LLM action collapse
to roughly the cost of a single batch when you fan out via
`delegate`. The parent emits one action that spawns K children in
parallel; each child handles a chunk of items in its own isolated
context; the parent aggregates only the final summaries. Target on
N>10 per-item workloads: ~70% wall-time reduction vs. the
single-action sequential shape.

### When to fan out

- **N > ~10 items**, and per-item work is independent (no item
  reads another item's output).
- **Item ordering doesn't matter** — children run concurrently and
  return in arbitrary order.
- **Per-item work is non-trivial** (each item needs LLM judgment +
  ≥1 tool call). For pure-formatting passes, the per-call delegate
  overhead exceeds the benefit.

Don't fan out when:
- N ≤ ~5 — sequential is faster end-to-end (single context warmup,
  no aggregation step).
- Items are dependent (item N's classification depends on items
  1..N-1) — must run sequential.
- Per-item work is sub-second (overhead dominates).

### The pattern

One `type: llm` parent action lists `delegate` in its `tools`. The
prompt instructs the LLM to chunk the input into K batches and call
`delegate` K times in parallel — modern LLMs (Claude Sonnet 4.6+)
emit parallel `tool_use` blocks in a single turn, so the K children
run concurrently. Each child runs in an isolated context with its
own tool allowlist; the parent's message buffer only sees each
child's final `answer`. The parent then emits one structured
aggregate doc.

### Worked example: triage 50 emails as 5 delegates of 10 each

```yaml
agents:
  triage-fanout:
    type: llm
    config:
      provider: anthropic
      model: claude-sonnet-4-6
      prompt: |
        You receive a list of N emails on the signal payload. Your
        job: classify and act on each one.

        If N > 10, split the list into batches of ~10 and call
        `delegate` once per batch IN PARALLEL (emit all delegate
        tool_use blocks in a single response — do not await one
        before issuing the next). Each child receives one batch and
        the same per-item instructions; it returns a JSON digest of
        per-email outcomes.

        After all children return, aggregate their digests into one
        structured `triage-summary` doc: total processed, counts by
        outcome, ids of items needing manual review.
      tools:
        - delegate
        - google-gmail/search_gmail_messages       # for the parent's pre-scan
      delegation:
        # Child tool surface — children inherit parent allowlist
        # plus these. Per-child budgets enforced by the runtime.
        child_tools:
          - google-gmail/get_gmail_message_content
          - google-gmail/batch_modify_gmail_message_labels

jobs:
  auto-triage:
    triggers:
      - signal: triage-now
    delegation:
      max_depth: 1               # children cannot themselves delegate
      max_steps_per_call: 12     # ~10 items + a couple of tool retries
      max_input_tokens: 40000    # bounded per child
      max_output_tokens: 4000    # digest only, not per-email prose
      max_wall_time_ms: 60000    # 60s per child; runaway → clean failure
    fsm:
      initial: idle
      states:
        idle:
          on: { triage-now: { target: fanout } }
        fanout:
          entry:
            - type: agent
              agentId: triage-fanout
              outputTo: triage-summary
              outputType: triage-summary
          type: final
```

### Delegation budgets cross-reference

Each child runs under the resolved `delegation:` budget — workspace
default merged with per-job override (per-field, job wins). Knobs:

- `max_depth` — children can't recursively fan out (default 1).
- `max_steps_per_call` — agent loop iterations per child.
- `max_input_tokens` / `max_output_tokens` — token bounds per
  child.
- `max_wall_time_ms` — wall-clock cap per child. A runaway child
  returns `{ ok: false, reason: "budget_exhausted: max_wall_time_ms" }`
  to the parent, which can choose to record the failure in the
  aggregate or fail the step. The other children still complete.

Full schema and merge precedence: see the `delegation:` section in
the `workspace-api` skill.

### Anti-patterns

- **Fanning out dependent work.** If item N needs item N-1's
  classification (e.g. dedupe across the batch), keep it
  sequential. Children can't see each other's state.
- **Sub-batch overhead.** Fanning out 4 items as 4 children of 1
  costs more than running 4 sequentially in one action — the
  per-child context warmup and aggregation round-trip dominate.
  Aim for chunks where per-child work is multiple seconds.
- **Unbounded fan-out.** Don't let the parent decide K from
  untrusted input — cap chunk size in the prompt, and rely on
  `delegation:` budgets as the runtime backstop. A 1000-item batch
  fanned out as 1000 children will exhaust the daemon, not finish
  faster.
- **Forgetting `outputType:` on the aggregate.** Without a
  structured contract, the parent emits free-form prose and the
  caller can't programmatically consume "what got triaged."

## Writing artifact summaries the supervisor can answer from

The supervisor (workspace-chat) sees `{ artifactIds, summary }` for
every job result and decides per turn whether to call `get_artifact`
to load the full body. **A thin `summary:` forces the supervisor to
re-fetch on every follow-up question** — wasted tokens and a slower
turn. A summary that carries the structural facts up front lets the
supervisor answer common queries (counts, top items, status flags,
key URLs) without re-fetching.

### What "self-sufficient" means

Aim for the summary to answer the questions a user is most likely
to ask after the job completes. For a triage job that's "how many
urgent? what got drafted? what needs my review?" For a search job
it's "how many hits? top result?" The summary should be the cheapest
data-store the supervisor consults — anything that takes a fetch
should be the exception, not the default.

Include, in this priority order:

1. **Counts of the things that matter.** "8 urgent, 12 normal, 3
   drafts created" beats "Triaged the inbox and acted on the
   urgent items." Counts are how supervisors answer "did anything
   change?" and "is this done?".
2. **Top items by name/title/id.** A handful of identifying labels
   so the supervisor can answer "which ones?" without a fetch.
   Cap at ~5 — the summary isn't the artifact.
3. **Key URLs.** If the job published to Notion, opened a PR, or
   sent a calendar invite, the URL belongs in the summary. The
   supervisor may need it next turn to deep-link.
4. **Status flags.** A boolean `status: "ok"` / `error_count: 0`
   / `requires_attention: true` lets the supervisor answer
   "anything to flag?" without scanning a long body.
5. **One-line digest of what was done.** The classic prose summary
   is still useful — but as the *header*, not the *whole thing*.

### Worked example: triage job

Bad — supervisor must `get_artifact` to answer anything:

```yaml
- type: agent
  agentId: triage-agent
  outputTo: triage-summary
  summary: "Triaged the inbox and took action on urgent items."
```

Good — supervisor answers "how many urgent?" and "what did you
draft?" from the summary alone:

```yaml
- type: agent
  agentId: triage-agent
  outputTo: triage-summary
  outputType: triage-summary
  summary: |
    Triaged 50 emails: 3 urgent (drafted replies), 12 normal (filed),
    35 noise (archived). Drafts await user review in Gmail.
    See artifact for per-email outcomes.
```

The structured `outputTo` document carries the per-email detail; the
`summary:` carries the answers to the supervisor's likely
follow-ups. Both ship to the supervisor on `job-complete`; only the
artifact body costs a `parse_artifact` round-trip.

### Auto-derived structural fingerprint

When you don't declare `summary:`, the runtime synthesizes one from
the terminal-state document's top-level fields. As of I3 the auto-
synthesis emits a structural digest rather than truncated JSON:

- Top-level array fields surface as `<key>: N items`.
- Top-level scalar fields (string/number/boolean) surface as
  `<key>: <value>`.
- Nested objects are skipped (too noisy at-a-glance).

So a doc like `{ status: "ok", actions: [...8...], flagged: [...3...] }`
auto-summarizes as `status: ok; actions: 8 items; flagged: 3 items`
— good enough for "how many?" without an author-provided string.

That said, the auto-derived digest is a *fallback*. When the answers
to likely supervisor questions need top item names or a URL, declare
`summary:` explicitly — the runtime can count arrays but doesn't
know which item names matter.

### Verify after authoring

After publishing the workspace, fire the signal once and inspect
the `summary` field on the SSE `job-complete` event:

- Does it answer "how many?" without a `parse_artifact`?
- Does it name the top items / surface a URL when relevant?
- Is it < 500 characters? (Hard cap is 5000; a summary much over
  500 chars usually means the artifact body leaked into it.)

If "no" to any of these, set `summary:` on the terminal action
explicitly. The `summary:` field is the contract you control to
keep the supervisor cheap.

## Requesting access to a tool not in your allowlist

When an action discovers it needs a real runtime-visible tool it didn't
declare, the action calls `request_tool_access(toolName, reason)`. Two paths:

- **Bypass on** (job or workspace `permissions.dangerouslySkipAllowlist:
  true`, or daemon `FRIDAY_DANGEROUSLY_SKIP_PERMISSIONS=1`): tool
  returns `{ ok: true, granted: true, reason: "bypass" }` synchronously.
  Action proceeds.
- **Bypass off** (default): the runtime creates a `tool-allowlist`
  elicitation surfaced in Activity/sidebar and **blocks the tool call**
  until the user answers, declines, or the elicitation expires. On allow,
  `request_tool_access` returns granted and the same action continues; on
  deny/expiry it returns a terminal denial that the action must handle.

Unknown tools are rejected without creating an elicitation. If
`request_tool_access` returns `reason: "unknown_tool"`, stop guessing: call
`list_capabilities` / `list_mcp_tools`, install/enable the correct server if
needed, or ask the user which real tool/provider they meant.

```yaml
- type: llm
  tools:
    - fs_read_file
    - request_tool_access
  prompt: |
    If you discover you need fs_write_file (not in your allowlist), call
    request_tool_access("fs_write_file", "Need to apply the requested patch").
    If granted, continue in thi

…(truncated)
