一键导入
writing-workspace-jobs
Author FSM workspace jobs. Use when creating, editing, or debugging jobs, signals, or FSM workflows in workspace.yml.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Author FSM workspace jobs. Use when creating, editing, or debugging jobs, signals, or FSM workflows in workspace.yml.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Create, list, update, delete, and clean up workspaces via the daemon HTTP API at $FRIDAYD_URL. Use when the user asks to create, edit, delete, or list workspaces, spaces, projects, or environments; add or patch signals / agents / jobs / memory / skills; convert a workspace.yml into a live workspace; wire up triggers (HTTP webhooks, cron, fs-watch, Slack / Telegram / WhatsApp); or clean up test/scratch workspaces.
Interacts with a running Friday daemon via CLI and HTTP — lists/creates/modifies workspaces, triggers signals, watches sessions, publishes skills and agents. Use whenever you need to poke at a local Friday daemon, inspect its state, fire a signal, drive the autopilot / self-modification flywheel, create a workspace programmatically, or validate that a workspace.yml you just authored actually runs. Also use when the task involves `$FRIDAYD_URL`, `deno task atlas`, curl-ing the daemon, or automating Friday itself.
Connects an external service's webhook (Bitbucket / Jira / GitHub / custom) to a workspace's HTTP signal via Friday's `/hook/raw/` tunnel URL. Use ONLY when the user asks how to point an upstream system at Friday: the URL shape, how to get the current tunnel host, the upstream UI steps, or the workspace.yml signal shape that accepts the body without stripping it. Everything that happens AFTER Friday receives the webhook — agent implementation, payload parsing, env wiring, HMAC verification, posting back to the upstream — is out of scope for this skill.
Authoring guide for Python user agents (type:"user") on the Friday platform via the friday-agent-sdk. Covers the @agent decorator, AgentContext capabilities (ctx.llm, ctx.http, ctx.tools, ctx.stream), structured input parsing, result types, and the NATS subprocess execution model. Load when an agent.py exists in scope, when imports from friday_agent_sdk are present, when an @agent decorator is being authored or modified, or when upsert_agent was just called with type:user. Do NOT load to decide whether to author a user agent — that decision belongs in the workspace-chat agent_types rules.
Authors Friday workspace signals with correct provider configs, payload schemas, and runtime wiring. Use when creating or editing signals in workspace.yml; when a signal needs to accept user input from the Run dialog; or when signal validation or runtime dispatch fails.
Use when choosing between install, enable, disable, or delete for an MCP server; when an agent needs to discover or delegate to MCP servers; or when MCP tools fail with credential, connection, or prefixing issues.
| name | writing-workspace-jobs |
| description | Author FSM workspace jobs. Use when creating, editing, or debugging jobs, signals, or FSM workflows in workspace.yml. |
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.
fsm: — execution: silently skipped by runtimeinitial state exists in states, handles trigger signal name in ontype: finalagentId values exist in agentsserverId/toolName formatemit event names match on transition keys exactlyoutputTo → inputFrom (array form when step needs >1 prior output)outputTo action has a complete output contract (outputType schema args, or { response } when untyped)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:
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:
fsm:
initial: idle
states:
idle:
on:
triage-now: { target: run }
run:
entry:
- type: agent
agentId: triage-agent
type: final
list_mcp_tools({ serverId: "google-gmail" }) returns bare names:
[{ "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.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 |
Copy, rename, publish. Every field required.
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 triggerstriggers — at least one { signal: <name> }fsm.initial — key in fsm.statesfsm.states — every non-final state needs on with outgoing transitionstype: final on terminal statesChain agents with outputTo → inputFrom. Each step emits DONE, transitions forward.
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 documentinputFrom: <doc-id> — feeds prior step output into next agent taskemit with event: DONE — signals engine to transitioninputFrom consumptionWhen 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:
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.
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.
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):
{
"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:
- 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.
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.
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 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.
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.
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.
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.
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.
Collapse two states into one when all are true:
outputTo: foo-result.Keep the two-state shape when any is true:
inputFrom: [a-result, b-result]
combining outputs from parallel branches.Before — two states, two LLM calls (~24s of redundant work on a real run):
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:
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.
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.
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.
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.
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:
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):
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:
ctx.llm.generate calls).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:
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
delegatewithout holding the items in its own buffer.
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.
- 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.
type: llm actionsYou 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.
complete is the only durable emission for outputToWhen 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:
outputType: <DocumentType>, call complete with fields that
match the declared document schema.outputType, call complete({ response: "<full final text>" }).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.
There is no validate: field. Output emission is captured via complete when outputTo is set — see @friday/agent-action-handshake for the contract.
type: llm actions can spawn a sub-agent via the delegate tool —
opt in by listing it:
- 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:
delegate. The child holds the items; the parent gets a digest.outputTo → inputFrom. No delegation needed.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.
Don't fan out when:
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.
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
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.
delegation: budgets as the runtime backstop. A 1000-item batch
fanned out as 1000 children will exhaust the daemon, not finish
faster.outputType: on the aggregate. Without a
structured contract, the parent emits free-form prose and the
caller can't programmatically consume "what got triaged."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.
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:
status: "ok" / error_count: 0
/ requires_attention: true lets the supervisor answer
"anything to flag?" without scanning a long body.Bad — supervisor must get_artifact to answer anything:
- 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:
- 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.
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:
<key>: N items.<key>: <value>.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.
After publishing the workspace, fire the signal once and inspect
the summary field on the SSE job-complete event:
parse_artifact?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.
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:
permissions.dangerouslySkipAllowlist: true, or daemon FRIDAY_DANGEROUSLY_SKIP_PERMISSIONS=1): tool
returns { ok: true, granted: true, reason: "bypass" } synchronously.
Action proceeds.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.
- 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 this same action and finish with complete(...).
If denied or expired, explain what was blocked and stop safely.
Authoring rule: only list request_tool_access when the action can continue
safely after an allow or produce a useful denial/partial result. Do not use it
as a way to ask for hallucinated tool names.
Use request_human_input for generic user decisions, approvals, or
disambiguation — for example, choosing email actions, confirming which account
to use, or selecting between safe alternatives. It creates an open-question
elicitation in Activity/sidebar, blocks the current tool call, and returns the
answer to the same action.
- type: llm
outputTo: review-actions
tools:
- request_human_input
prompt: |
Present the choices, then call request_human_input with a clear question
and options such as [{ label: "Archive", value: "archive" }, ...].
After the tool returns { status: "answered", answer: ... }, finish with
complete({ response: ... }). If declined or expired, stop safely.
Python type: user agents use the same primitive through
ctx.tools.call("request_human_input", {"question": ..., "options": [...]}).
Do not fake this by streaming a menu and hoping a later chat turn resumes the
FSM action; without request_human_input, an outputTo action must complete in
one execution and will fail if it waits for a future user message.
request_human_input has a flat options contract. Do not pass unsupported
fields such as multi_select. For repeated decisions such as "pick one action
for each email", encode the item id in each option value and label so the UI can
render nested choice sets:
{
"question": "Review your inbox — select an action for each email:\n\n[1] From: ...\n[2] From: ...",
"options": [
{ "label": "[1] Archive — Invoice", "value": "1:archive" },
{ "label": "[1] Keep — Invoice", "value": "1:keep" },
{ "label": "[2] Archive — Investor update", "value": "2:archive" },
{ "label": "[2] Keep — Investor update", "value": "2:keep" }
]
}
Grouped option answers come back as an answer string containing a JSON array of
selected values, for example ["1:archive","2:keep"]; parse it before
executing side effects. Optional per-item comments may appear in the returned
note field. For complex comments or free-form data per item, ask one item at
a time instead of flattening everything into one huge option list.
Elicitation timeout. Prompts inherit the parent job's config.timeout and
expire when the job times out:
jobs:
triage:
config:
timeout: 30m
Expired elicitations move to a read-only Activity log entry; acting on one does not reify the timed-out job.
config block — keys are snake_casejobs.<id>.config carries a few runtime knobs. The key names are
snake_case and the schema is loose — an unrecognized key (a
camelCase typo, a misremembered name) is silently accepted and
ignored, no validation error. So a wrong key is a silent no-op, not a
failure you'll see in validate_workspace.
jobs:
pipeline-auto-repair:
config:
max_steps: 30 # agent-loop step cap for the job's LLM actions
timeout: 30m # job wall-clock; also the elicitation TTL
Common miss: maxSteps (camelCase) does nothing — the runtime reads
config.max_steps. If an action keeps hitting "did not call complete"
because it runs out of steps, raise config.max_steps — and confirm
you spelled it snake_case.
A few behaviors fire automatically. You don't opt in or out, but knowing they exist saves debugging time.
outputTo document is bulky, the runtime writes it to
the JetStream Object Store and carries compact refs/summaries at supervisor
boundaries. Downstream same-session inputFrom actions are hydrated before
the model sees them, so keep using outputTo → inputFrom instead of
scraping session logs or re-fetching artifacts manually. If you see
unexpected artifacts in GET /api/artifacts?sessionId=..., this is the
source.parentSessionId + parentEventId; every step:complete event has
usage: { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, model }. You don't write these — but the Activity / sessions API
surfaces them, and crystallization (future) reads them.Validator returns errors (blocks publish) and warnings (advisory).
| Code | Path | Fix |
|---|---|---|
unknown_tool | agents.X.config.tools[N] | Add serverId/ prefix for MCP tools; check spelling for built-in tools |
unknown_agent_id | jobs.Y.fsm.states.Z.entry[N].agentId | Declare agent in agents before job; check spelling |
unknown_memory_store | agents.X | Add memory to memory.own or memory.mounts |
fsm_structural_error | jobs.Y.fsm.<path> | Verify initial exists, all on targets exist, no type: action (use entry array) |
invalid_type | (varies) | Read message field for expected vs received type |
| Code | Ignore when | Fix when |
|---|---|---|
orphan_agent | Agent referenced inside valid FSM entry — validator skips malformed FSMs during cross-check. Fix FSM first. | Agent genuinely unused — wrap in job or delete. |
dead_signal | Signal reserved for future use or external callers. | Signal genuinely orphaned — add job trigger or delete. |
missing_tools_array | Agent has tool_choice: none or needs no tools. | Agent should call tools — add tools array. |
cron_parse_failed | Never ignore. | Fix expression; use crontab.guru to validate. |
http_path_collision | Never ignore. | Change one signal's config.path to unique route. |
Pass validation but fail or misbehave at runtime.
execution: instead of fsm:Config schema accepts execution: for backward compat, but runtime only executes FSM jobs. Non-FSM jobs silently skipped.
Symptom: "No FSM job handles signal '<name>'"
Fix: Rewrite as fsm: with initial and states.
type: atlas agents self-contained — they ignore tools array. Adding tools: ["google-gmail/send_gmail_message"] to atlas agent = silently ignored. Bundled agent runs hard-coded behavior.
Symptom: Agent completes without error, but MCP tool never called. Output shows bundled agent default behavior (e.g., SendGrid instead of Gmail, generic web search instead of GitHub API).
Fix: Change to type: llm, set config.provider and config.model, move prompt into config.prompt, keep tools array. Or drop tools array entirely and let bundled agent handle domain.
initial state must handle trigger signal name in on map. Without it, signal silently ignored.
Symptom: Job triggers but produces no output, no error, no session.
Fix: Add on: { <signal-name>: { target: <work-state> } } to initial state.
FSM engine catches this in validateFSMStructure, but workspace config validator does not validate FSM internals (fsm is z.any() in JobSpecificationSchema).
Symptom: FSM reaches stuck state, session hangs.
Fix: Add transitions to every non-final state, or mark type: final.
type: action in statesFSM engine supports agent, llm, and emit action types. type: action is legacy pre-FSM shape.
Symptom: fsm_structural_error at MCP-registry validator (second validation layer).
Fix: Use entry: [ { type: agent, agentId: ... } ].
emit action sends event to FSM engine. Transition must match emitted event name exactly.
entry:
- type: emit
event: DONE
on:
DONE: { target: completed } # matches
Emit COMPLETE, transition on DONE = event queued, never consumed.
Symptom: Agent runs but session never completes.
Fix: Match emit event name to on key exactly.
outputTo in pipelinesWithout outputTo, agent result not saved as document. Next step's inputFrom has nothing to read.
Symptom: Second agent receives empty or undefined input.
Fix: Add outputTo: <doc-id> to producer and inputFrom: <same-doc-id> to consumer. For LLM-backed producers, also make sure the action finishes with complete(...) (schema args when outputType is set, { response } otherwise).
inputFrom reaching across sessionsinputFrom only sees documents from the current FSM session. A separate signal starts a fresh session with an empty doc store — inputFrom: foo-result referring to a document produced by another job's session resolves to "(none)" and the session fails before the agent runs.
Symptom: Signal '<name>' session failed: inputFrom: document '<id>' not found. Available documents: (none).
Fix: pass the data on the signal payload — see "Crossing session boundaries".
Build workspace in this order:
agentId in FSM entry actions.Validator catches unknown_agent_id when job references missing agent.
Validation fails 3+ times on same operation:
version: "1.0" + workspace.name), confirm validates cleanly.validate_workspace after each addition.Removes panic-driven shotgun debugging producing orphaned agents, malformed FSMs, circular retries.
assets/minimal-job-template.yml — drop-in single-agent job. Copy, rename, publish.assets/multi-step-job-template.yml — chained agent pipeline with outputTo → inputFrom wiring.writing-workspace-signals — Signal authoring: provider names + config shapes, JSON Schema payloads, HTTP path collisions, cron validation. Load alongside this skill for any job — the trigger signal is part of the job, and provider/config shapes are not duplicated here.workspace-api — Workspace building workflow: draft mode, CRUD, validation, reachability model, tool selection.writing-to-memory — Artifact + memory reference pattern for the cross-session bridge described above. Load when a job needs to hand large or persistent data to a later session.