Writing Workspace Jobs logo

Writing Workspace Jobs

Organization
friday-platform
writing-workspace-jobs

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

Overview

Publisherfriday-platform
Repositoryfriday-studio
Skill namewriting-workspace-jobs
Stars
102
Forks
5
Bundled files
2
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 2 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by friday-platform on GitHub. Read the source before you install it.

Installation

Install the Writing Workspace Jobs AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/friday-platform/friday-studio/tree/main/packages/system/skills/writing-workspace-jobs
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/friday-platform/friday-studio.git /tmp/friday-studio
mkdir -p .claude/skills
cp -r /tmp/friday-studio/packages/system/skills/writing-workspace-jobs .claude/skills/writing-workspace-jobs
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Writing Workspace Jobs in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Writing Workspace Jobs on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Writing Workspace Jobs is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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 returnsWrite 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 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.

Asking the user for a decision mid-job

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.

yaml
- 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:

json
{
  "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:

yaml
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.

Job config block — keys are snake_case

jobs.<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.

yaml
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.

Runtime invariants you don't author

A few behaviors fire automatically. You don't opt in or out, but knowing they exist saves debugging time.

  • Oversized tool results and action documents auto-lift to artifacts. When an MCP tool or 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.
  • Provenance metadata is captured. Every spawned session carries 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.

Validation error decoder

Validator returns errors (blocks publish) and warnings (advisory).

Errors

CodePathFix
unknown_toolagents.X.config.tools[N]Add serverId/ prefix for MCP tools; check spelling for built-in tools
unknown_agent_idjobs.Y.fsm.states.Z.entry[N].agentIdDeclare agent in agents before job; check spelling
unknown_memory_storeagents.XAdd memory to memory.own or memory.mounts
fsm_structural_errorjobs.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

Warnings

CodeIgnore whenFix when
orphan_agentAgent referenced inside valid FSM entry — validator skips malformed FSMs during cross-check. Fix FSM first.Agent genuinely unused — wrap in job or delete.
dead_signalSignal reserved for future use or external callers.Signal genuinely orphaned — add job trigger or delete.
missing_tools_arrayAgent has tool_choice: none or needs no tools.Agent should call tools — add tools array.
cron_parse_failedNever ignore.Fix expression; use crontab.guru to validate.
http_path_collisionNever ignore.Change one signal's config.path to unique route.

Runtime anti-patterns

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.

Atlas agent silently ignoring MCP tools

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 with no signal handler

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.

Non-final state with no outgoing transitions

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 states

FSM 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 / transition name mismatch

emit action sends event to FSM engine. Transition must match emitted event name exactly.

yaml
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.

Missing outputTo in pipelines

Without 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 sessions

inputFrom 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".

Order of declaration

Build workspace in this order:

  1. Agents first — jobs reference agentId in FSM entry actions.
  2. Jobs second — wire agents into orchestration layer.
  3. Signals last — external entry points; nothing else depends on them.

Validator catches unknown_agent_id when job references missing agent.

Stuck-recovery heuristic

Validation fails 3+ times on same operation:

  1. Build minimum viable config (version: "1.0" + workspace.name), confirm validates cleanly.
  2. Add one section at a time in order: signals → agents → jobs.
  3. Call validate_workspace after each addition.
  4. First section that breaks = debug target. Fix before adding next.

Removes panic-driven shotgun debugging producing orphaned agents, malformed FSMs, circular retries.

Assets

  • 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.

Companion skills

  • 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.

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Writing Workspace Jobs AI skill do?

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

Why use Writing Workspace Jobs on TypingMind?

Because you install it once and use it with any model. Writing Workspace Jobs is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Writing Workspace Jobs in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/friday-platform/friday-studio/tree/main/packages/system/skills/writing-workspace-jobs. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Writing Workspace Jobs?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Writing Workspace Jobs?

As many as you like. As long as a model supports skills, you can use Writing Workspace Jobs with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Writing Workspace Jobs AI skill free?

It is published on GitHub by friday-platform. Check the repository for licensing terms. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇