Workflow Authoring logo

Workflow Authoring

OrganizationPopular
letta-ai
workflow-authoring

Reference for writing a Workflow tool script (script API and gotchas, pipeline-vs-barrier rules, quality patterns, worked examples). Load before authoring a script for a workflow the user already opted into; it does not itself authorize running one.

Overview

Publisherletta-ai
Repositoryletta-code
Skill nameworkflow-authoring
Stars
3.6K
Forks
432
Bundled files
Instructions only
LicenseApache-2.0
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.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by letta-ai on GitHub. Read the source before you install it.

Installation

Install the Workflow Authoring 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/letta-ai/letta-code/tree/main/src/skills/builtin/workflow-authoring
  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/letta-ai/letta-code.git /tmp/letta-code
mkdir -p .claude/skills
cp -r /tmp/letta-code/src/skills/builtin/workflow-authoring .claude/skills/workflow-authoring
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Workflow Authoring 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 Workflow Authoring 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 Workflow Authoring 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.

Workflow authoring reference

A workflow structures work across many subagents — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before committing), or to take on scale one context can't hold (migrations, audits, broad sweeps). The script is where you encode that structure: what fans out, what verifies, what synthesizes.

When you do call the Workflow tool, the right move is often hybrid: scout inline first (list the files, scope the diff, find the modules) to discover the work-list, then call Workflow to pipeline over it, passing the list via args. You don't need to know the shape before the task — only before the orchestration step.

Common single-phase workflows you can chain across turns:

  • Understand — parallel readers over relevant subsystems → structured map
  • Design — judge panel of N independent approaches → scored synthesis
  • Review — dimensions → find → adversarially verify
  • Research — multi-modal sweep → deep-read → synthesize
  • Migrate — discover sites → transform each → verify

For larger work, run several in sequence — read each result before deciding the next phase. You stay in the loop; each workflow is one well-scoped fan-out.

Every script must begin with export const meta = {...}:

export const meta = {
  name: 'find-flaky-tests',                       // kebab-case, required
  description: 'Find flaky tests and propose fixes',  // required
  phases: [                                       // one entry per phase() call
    { title: 'Scan', detail: 'grep test logs for retries' },
    { title: 'Fix', detail: 'one agent per flaky test' },
  ],
}
// script body starts here — use agent()/parallel()/pipeline()/phase()/log()

The meta object must be a PURE LITERAL — no variables, function calls, spreads, or template interpolation.

What a subagent is

Every agent() call is one agent-free ephemeral conversation linked to the invoking parent agent (is_subagent: true, named after the call's label). It starts with nothing but its prompt: no memory, no conversation context, no skills. Put ALL context a stage needs in the prompt — file paths, the rule it should apply, what shape to return.

Subagents are told their final text IS the return value (not a human-facing message), so they return raw data. Workers inherit the invoking session's tools and permission mode; use allowedTools to restrict a workflow or stage. For stages whose input is entirely in the prompt (synthesis, judging, scoring) pass allowedTools: [] — a model that can still read files tends to wander, and a subagent that re-issues an identical tool call three times is stopped and rejects with the failure detail.

Their model defaults to the invoking conversation's model. opts.model (or the tool's model input) accepts any handle or alias listed by letta model list; an unknown value rejects that call. Use a cheaper model for mechanical stages only when you know a valid handle.

Use the invoking backend for workflow workers. Local execution requires an Agent SDK and App Server with local conversation support; decide() still uses the Cloud decisions service.

Script body hooks

  • agent(prompt, opts?) → Promise. Spawn one subagent. Resolves to its final text, or with schema (JSON Schema) to a validated object. Prefer schema for shaped results: invalid or missing output is retried, then rejects with validation detail in the error and journal. json: true still parses without validating; schema wins if both are set. Await each call; failures reject with the cause, callIndex, and conversationId when available. Catch errors explicitly to recover. Options: label (display name), phase (progress group — use this inside concurrent stages), schema, json, model, effort ('low' for mechanical stages, higher for the hardest verify/judge stages), allowedTools, systemPrompt (extra system prompt for this subagent), timeoutMs (default 10 minutes), maxToolCalls (positive safe integer; default 1000 unique tool calls for this subagent), conversationId (resume a worker — see "Diagnosing a run").

  • pipeline(items, stage1, stage2, ...) → run each item through all stages independently, NO barrier between stages. Item A can be in stage 3 while item B is still in stage 1. This is the DEFAULT for multi-stage work. Wall-clock = slowest single-item chain, not sum-of-slowest-per-stage. Every stage callback receives (prevResult, originalItem, index) — use originalItem/index in later stages to label work without threading context through stage 1's return value. A stage that throws skips that item's remaining stages; siblings finish, then the helper rejects.

  • parallel(thunks) → run zero-arg functions concurrently. This is a BARRIER: it awaits all thunks, then rejects if any threw. For best-effort processing, catch errors inside each thunk and return explicit success/error results. Use ONLY when you genuinely need all results together.

  • phase(title) — start a new phase; subsequent agent() calls are grouped under this title in progress output.

  • log(message) — emit a progress message to the user.

  • decide(state, questions, opts?) → Promise; not a subagent call. Ask a calibrated Jev model (chosen internally — no model option) questions about state. questions is a non-empty OBJECT keyed by question id — never an array. Each question needs instructions and a type: choice (criteria map of id → description, ≤255), score (criteria array of strings, unlike questions), noul (no criteria). Returns a response whose answers are keyed by the same ids and calibrated, or null after one retried invalid answer — guard if (!call); API errors throw.

    const call = await decide(evidence, {
      behavior: { type: 'choice', instructions: 'Is this behavior a bug?',
        criteria: { bad: 'Wrong or harmful', not_bad: 'Expected or harmless' } },
    })
    const verdict = call?.answers?.behavior?.choice  // 'bad' | 'not_bad'
  • tools.mcp__server__tool(args?) → Promise; not a subagent call. Calls one of the invoking agent's MCP tools directly (find names with letta mcp search / letta mcp tools before writing the script). Each call goes through the same permission rules as a normal MCP tool call; a tool that would ask for approval throws instead, so the user must allow it first. Resolves to the tool's structuredContent, else its text output (parsed when it is JSON), else the raw content array. Throws when the tool is unavailable, not allowed, or reports an error; parallel() and pipeline() propagate that rejection. Use it for deterministic fetches and writes whose arguments the script already knows; use agent() when a step needs judgment.

    const issues = await tools.mcp__linear__list_issues({ team: 'LET', limit: 20 })
  • args — the value passed as the tool's args input, verbatim. Pass arrays/objects as actual JSON values, NOT as a JSON-encoded string.

decide() sees only the state you pass; it cannot read files. When compact, bounded items are already prepared, pass them via args and call decide() on each directly — don't spawn agent() readers just to relay inputs. Raw large traces don't belong in args: prepare bounded state that preserves the user instruction, observed action, and outcome, and mark what was omitted.

Scripts are plain JavaScript, NOT TypeScript — type annotations, interfaces, and generics fail to parse. The script body runs in an async context — use await directly and return the final result. Standard JS built-ins (JSON, Math, Array, etc.) are available; the hooks are the only globals provided. The script runs inside the CLI process with the CLI's own privileges (the vm context is a scope, not a security boundary), and the user approves it by reading it. Keep the script to orchestration: decide what runs and combine results. Direct MCP calls belong in tools.mcp__*(); other reading, searching, and writing belongs in subagents, where the tool allowlist applies.

Pipeline vs barrier

DEFAULT TO pipeline(). Only reach for a barrier (parallel between stages) when stage N needs cross-item context from all of stage N−1:

  • Dedup/merge across the full result set before expensive downstream work
  • Early-exit if the total count is zero ("0 bugs found → skip verification")
  • Stage N's prompt references "the other findings" for comparison

A barrier is NOT justified by:

  • "I need to flatten/map/filter first" — do it inside a pipeline stage: pipeline(items, stageA, r => transform([r]).flat(), stageB)
  • "The stages are conceptually separate" — that's what pipeline() models. Separate stages ≠ synchronized stages.
  • "It's cleaner code" — barrier latency is real. If 5 finders run and the slowest takes 3× the fastest, a barrier wastes 2/3 of the fast finders' idle time.

agent() and decide() share one pool of concurrent slots per run (maxConcurrent, default 16) — excess calls queue and run as slots free up, so passing 100 items is fine. Each has its own 1000-call lifetime backstop. A single parallel()/pipeline() call accepts at most 4096 items.

When a barrier IS correct — dedup across all findings before expensive verification:

const all = await parallel(DIMENSIONS.map(d => () => agent(d.prompt, {json: true})))
const deduped = dedupeByFileAndLine(all.filter(Boolean).flatMap(r => r.findings ?? []))  // needs ALL at once
const verified = await parallel(deduped.map(f => () => agent(verifyPrompt(f), {json: true})))

Loop-until-count pattern — accumulate to a target:

const bugs = []
let rounds = 0
while (bugs.length < 10 && rounds++ < 5) {
  const result = await agent('Find bugs in this codebase. Reply {"bugs": [{file, line, summary}]}', {json: true})
  bugs.push(...(result?.bugs ?? []))
  log(`${bugs.length}/10 found`)
}

Always bound a loop with a round counter as well as the target: a subagent that keeps returning nothing would otherwise run to the 1000-agent cap.

Composing patterns — exhaustive review (find → dedup vs seen → diverse-lens panel → loop-until-dry):

const seen = new Set(), confirmed = []
let dry = 0
while (dry < 2) {                                              // loop-until-dry
  const found = (await parallel(FINDERS.map(f => () =>          // barrier: collect all finders this round
    agent(f.prompt, {phase: 'Find', json: true})))).filter(Boolean).flatMap(r => r.bugs ?? [])
  const fresh = found.filter(b => !seen.has(key(b)))           // dedup vs ALL seen — plain code, not an agent
  if (!fresh.length) { dry++; continue }
  dry = 0; fresh.forEach(b => seen.add(key(b)))
  const judged = await parallel(fresh.map(b => () =>           // every fresh bug judged concurrently...
    parallel(['correctness','security','repro'].map(lens => () =>   // ...each by 3 distinct lenses
      agent(`Judge "${b.desc}" via the ${lens} lens — real? Reply {"real": boolean}`, {phase: 'Verify', json: true, allowedTools: []})))
      .then(vs => ({ b, real: vs.filter(Boolean).filter(v => v.real).length >= 2 }))))
  confirmed.push(...judged.filter(v => v.real).map(v => v.b))
}
return confirmed
// dedup vs `seen`, NOT `confirmed` — else judge-rejected findings reappear every round and it never converges.

Quality patterns — pick by task and compose freely

  • Adversarial verify: spawn N independent skeptics per finding, each prompted to REFUTE. Kill if ≥majority refute. Prevents plausible-but-wrong findings from surviving.

    const votes = await parallel(Array.from({length: 3}, () => () =>
      agent(`Try to refute: ${claim}. Default to refuted=true if uncertain. Reply {"refuted": boolean}`, {json: true})))
    const survives = votes.filter(Boolean).filter(v => !v.refuted).length >= 2
  • Perspective-diverse verify: when a finding can fail in more than one way, give each verifier a distinct lens (correctness, security, perf, does-it-reproduce) instead of N identical refuters — diversity catches failure modes redundancy can't.

  • Judge panel: generate N independent attempts from different angles (e.g. MVP-first, risk-first, user-first), score with parallel judges, synthesize from the winner while grafting the best ideas from runners-up. Beats one-attempt-iterated when the solution space is wide.

  • Loop-until-dry: for unknown-size discovery (bugs, issues, edge cases), keep spawning finders until K consecutive rounds return nothing new. Simple counters (while count < N) miss the tail.

  • Multi-modal sweep: parallel agents each searching a different way (by-container, by-content, by-entity, by-time). Each is blind to what the others surface; useful when one search angle won't find everything.

  • Completeness critic: a final agent that asks "what's missing — modality not run, claim unverified, source unread?" What it finds becomes the next round of work.

  • No silent caps: if a workflow bounds coverage (top-N, no-retry, sampling), log() what was dropped — silent truncation reads as "covered everything" when it didn't.

Scale to what the user asked for. "find any bugs" → a few finders, single-vote verify. "thoroughly audit this" or "be comprehensive" → larger finder pool, 3–5 vote adversarial pass, synthesis stage. When unsure, lean toward thoroughness for research/review/audit requests and toward brevity for quick checks.

Diagnosing a run

Every run persists its script, args, and a journal.jsonl with one line per completed subagent call (prompt, outcome, conversation id) under ~/.letta/workflows/executions/<id>/; the tool result names the paths. Before diagnosing why a workflow returned an empty or unexpected result, read that journal — it records each agent's actual return value and failure detail. A failed run is not resumable: fix the script and launch it again.

The script is never replayed, but one worker can continue: agent(prompt, {conversationId}) re-prompts it with history and model intact, using a journal ID and only once its last Run is terminal. Local workers can only continue inside the same workflow execution that observed their completed turn. Tools inherit the invoking session unless allowedTools is supplied; schema and effort are chosen per turn. Needs SDK 0.8.20+.

Frequently asked questions

What does the Workflow Authoring AI skill do?

Reference for writing a Workflow tool script (script API and gotchas, pipeline-vs-barrier rules, quality patterns, worked examples). Load before authoring a script for a workflow the user already opted into; it does not itself authorize running one.

Why use Workflow Authoring on TypingMind?

Because you install it once and use it with any model. Workflow Authoring 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 Workflow Authoring in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/letta-ai/letta-code/tree/main/src/skills/builtin/workflow-authoring. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Workflow Authoring?

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 Workflow Authoring?

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

Is the Workflow Authoring AI skill free?

Yes. It is published on GitHub by letta-ai under the Apache-2.0 license. 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 👇