Create a new workflow.
## KG-First — before you write any prompt content
If the workflow you're building contains workspace-specific content (investment thesis, ICP criteria, scoring rubrics, target sectors, brand voice, geo focus, seed lists), run this preflight before generating AI-step prompt strings:
1. **Inspect** — call `list_memories`, `list_knowledge_lists`, `get_knowledge_text` to see what already exists in the workspace KG.
2. **Seed** — if the content isn't in the KG yet, write it there first (`store_memory`, `upsert_knowledge_text`, `upsert_knowledge_rows`). Confirm with the user before seeding new content.
3. **Reference** — pull KG content at runtime in the step (`kg.read-text`, `kg.read-list`, `recall_memory`). Never paste workspace-specific paragraphs into prompt template strings.
**Boundary: strategy → KG; execution wiring → workflow context; workflow structure → workflow.**
## Recommended flow for agent-authored workflows
`create_workflow({ name, goal })` → `add_step` per step → `validate_workflow` → `publish_workflow`.
Call this with **only** `name` + `goal` — leave `steps` empty. Then `add_step` one step at a time. The incremental path catches errors immediately and surfaces `{{input.X}}` / `{{steps.Y.Z}}` bindings per-step. Internal testing: 0 errors incremental vs 13 errors bulk.
`pipeline.steps` is supported for imports/templates/round-trips only. Agents authoring from scratch should not use it.
## Pipeline object fields
- `name` (required), `goal`, `description`, `context`, `metadata`, `style`
- `steps`: Closed `type` values (others silently stripped): `trigger`, `appAction`, `aiAction`, `aiActionWithTools`, `toolAction`, `code`, `knowledgeSync`, `return`, `milestone`, `share`, `wait`, `branch`, `parallel`, `loop`, `end_if`, `agentOrchestrator`.
Prefer `schedule` triggers for email intake and non-real-time use — idempotent, no webhook infra. Use `app_event`/`webhook` only for "as soon as" / "real-time" requirements.
For child / sub-workflows that end in a `return` step and are only invoked via `agentled.call-workflow`, set `context.executionInputConfig.internal: true` to hide the Run button in the UI (see `update_workflow` for details).
## Step shape reference
Call `get_step_schema({ stepType, shape? })` for the authoritative field schema AND minimal JSON examples of every common step shape. The highest-friction shapes are:
- **Report step** (aiAction with Config renderer) — `get_step_schema({ stepType: "aiAction", shape: "report" })`.
- **Composed email with approval** — `get_step_schema({ stepType: "aiAction", shape: "email" })`. Use this with `schedule-email`; do not add Gmail/Outlook send appActions unless explicitly requested.
- **Agentic research** (web_search + workspace_memory) — `get_step_schema({ stepType: "aiActionWithTools", shape: "agentic-search" })`.
- **Agent Team** (agentOrchestrator) — `get_step_schema({ stepType: "agentOrchestrator", shape: "supervisor" })`.
- **Public share URL for a report** — `get_step_schema({ stepType: "share", shape: "public" })`.
- **KG field mapping** — `get_step_schema({ stepType: "knowledgeSync", shape: "standard" })`.
- **Code step** (JavaScript transformation) — `get_step_schema({ stepType: "code", shape: "standard" })`. JavaScript only — Python is not supported.
For app actions, call `get_app_actions({ appId })` for input/output schemas. For models, call `list_models`.
## Composable Step Blocks
**Search & Extract**: `aiAction (queries) → appAction (search) → aiAction (extract)`
⚠️ NEVER pass raw user input (job titles, topics) directly to a search API — always generate optimized boolean/keyword queries first.
**Enrich & Score**: `appAction (fetch) → aiAction (score)`
**Draft & Send**: `aiAction email → approval + schedule-email`. Use HTML bodies for open/click tracking.
**Write app action with approval**: put the approval directly on the write `appAction` (`preExecuteApproval: true`, `onApproval.action: "execute-approved-action"`, `onApproval.target.type: "current-step"`). Do not add a separate "Approve X" milestone/wait step before the write action, and do not add a separate "Mark X sent/published" step unless it records the real post-send result.
**Report & Notify**: `aiAction report with Config renderer → share step → aiAction notification email with concise HTML overview + shareUrl`
**Loop Enrich & Filter**: `loopConfig on first step → appAction (enrich) → aiAction (score)`
## Available Apps & Data Sources
**Before proposing data sources or sourcing channels to the user, you MUST know what is actually reachable.** The catalog has two billing models — don't conflate them, and don't invent connectors that don't exist. Always call `list_apps` and `get_app_actions({ appId })` to verify before finalizing a plan.
### Billed via Agentled credits (no user setup needed)
- **agentled native LinkedIn / email**: `get-linkedin-profile-from-url`, `get-linkedin-company-from-url`, `find-email-person-domain`, `get-emails-from-company-domain`.
- **agentled native LinkedIn search & content**: `linkedin-post-search` (keyword post search — primary LinkedIn discovery surface), `linkedin-jobs`, `linkedin-profile-posts`, `linkedin-company-posts`.
- **email finder**: `hunter`.
- **web fetch / scrape**: `web-scraping.scrape` (any URL → markdown), `http-request.request`, `page-index`.
- **browser automation**: `browser-use.run-task` / `extract-data`, `anthropic-computer-use`, `openai-computer-use`.
- **AI / image gen**: `openai`, `google-gemini`, `mistral`, `bytedance`, `kling`.
- **public data feeds**: `french-gouv`, `google-maps`, `realtor`, `seloger`, `amazon`, `ad-intelligence`, `upwork`, `instagram`, `facebook`.
- **knowledge graph**: `kg.*` (read-list, upsert-rows, update-rows, traverse-edges, etc.) — 1 credit per call.
- **comms**: `gmail`, `google-calendar`, `webhook` (Slack/Discord), `notion`.
### Bring-your-own-key (NOT billed via Agentled credits)
These require the user to connect their own account / paste their own API key. Treat them as available only if the user has the integration connected.
- `crunchbase` (user's Crunchbase API key)
- `specter` (user's Specter API key)
- `affinity-crm` (user's Affinity API key)
- `phantombuster` (user's PhantomBuster account — runs LinkedIn search agents, Sales Navigator scrapers, etc.)
When proposing one of these, ask "do you already have a <service> account connected?" before assuming you can use it.
### Built-in tools (for `aiActionWithTools` steps, not standalone apps)
`web_search`, `file_search`, `code_interpreter`, `fetch_website_content`, `kg_search`, `kg_traverse`, `kg_nodes`, `kg_write`, `workspace_memory`. Attach via the step's `tools` array; the AI decides at runtime whether to call them.
### Common gotchas
- Want LinkedIn keyword/post search? Use the native `linkedin-post-search` (Agentled credits) — see `deal-sourcing-linkedin-founder-signals.ts` for a reference workflow.
- Want LinkedIn Sales Navigator search / lead lists / company employees? `phantombuster` (BYOK) — see `deal-sourcing-linkedin.ts`.
- No native ProductHunt / EU-Startups / X-Twitter connector — use `web-scraping.scrape` on a known URL or `web_search` via `aiActionWithTools`.
- LinkedIn profile / company *enrichment* is URL-only (`get-linkedin-profile-from-url`). For discovery, pair it with `linkedin-post-search` or `phantombuster`.
## Multi-Workflow Architecture (Source → KG → Process)
Before building a multi-workflow goal, call `list_use_cases` or `get_use_case`; reuse matching `workflowGraphId`, KG refs, agents, and workflows. Tag new bundles with `metadata.workflowGraph.id`.
For a new multi-source/shared-tail goal, call `preview_use_case_kit` first and review its dry-run operations with the user. If the ask fits Source -> KG -> Process, **do not hand-roll** N disconnected `create_workflow` calls from scratch.
When the user wants to "find leads", "source startups", "build a list to act on later", or run anything on a recurring cadence that produces entities to act on, **do not build one monolithic workflow**. Build several:
1. **One sourcing workflow per channel/theme** (e.g. "LinkedIn cybersecurity startups", "YC W25 batch", "ProductHunt this week"). Each runs on its own schedule and writes to a **shared KG list** via `kg.upsert-rows` with: `userKey` = stable id (URL/domain/LinkedIn URL) for O(1) dedup across runs; `status: "new"` to mark rows for downstream processing; `mergeStrategy: "merge"` so fields added later (scores, outreach status) survive re-upserts.
2. **One orchestrator/qualifier workflow** that runs on its own cadence (e.g. weekly), reads `kg.read-list({ filters: { status: "new" } })`, qualifies/scores each row against the current theme, then either dispatches to outreach or marks `status: "qualified" / "rejected"`.
3. **One outreach workflow** (often a child workflow called via `agentled.call-workflow`) that the orchestrator invokes for qualified rows.
Why split: sourcing cadences, qualification criteria, and approval-gated outreach evolve independently. Multiple sourcing workflows converging on one `listKey` is the canonical pattern.
Suggest the split explicitly ("N sourcing workflows + 1 qualifier + 1 outreach") instead of a mega-workflow.
## Build incrementally — two first, then refactor, then the rest
When the plan calls for many sourcing workflows (or any N near-identical workflows), **do not build all N upfront**. Build two first, ship them end-to-end, then extract what is actually shared — most often the *tail*: normalize → kg.upsert-rows (with userKey + status: "new" + mergeStrategy: "merge") → milestone.
Once the shared shape is clear:
1. Extract the common tail into a **child workflow** (terminal `return` step, `context.executionInputConfig.internal: true`) and have the two existing sourcing workflows call it via `agentled.call-workflow`.
2. Validate + run the two pilots end-to-end on the shared tail.
3. *Then* build the remaining sourcing workflows on top of that shared tail — they become small (just the source-specific search/scrape/extract head, then call the shared tail).
Why: two pilots surface the real shared shape; refactoring before scaling keeps workflows 3..N short and consistent. Don't pre-extract a child workflow before the second pilot exists.
## KG Status Lifecycle — multi-phase pipeline pattern
When a workflow acts on entities across phases (source → score → report → outreach), use KG row `status` as a DB-indexed state machine. Filtering by one status (e.g. `status: "new"`) is indexed — never scan the full list and filter in code.
**Status values are user-defined** — choose names that map to your pipeline phases (e.g. `new → scored → reported → email_sent → closed_*`, or `draft → review → approved → published`). Document the state machine in the workflow goal or as a KG text entry.
Key rules:
- Sourcing writes `status: "new"` via `kg.upsert-rows` with `mergeStrategy: "merge"` (preserves downstream-added fields across re-runs).
- Each phase reads only its input status tier and advances rows to the next.
- Mark the next status **before** side-effects (email, share, Slack). If delivery fails, the row stays in the new status — it won't be double-sent on retry.
- Every upserted row needs a `userKey` (URL, domain, LinkedIn URL, email) for O(1) cross-run dedup.
- Use `entryConditions.criteria[{ type: "loop_completion" }]` with `onCriteriaFail: "wait"` before cross-phase reads that depend on a loop finishing.
The `Loop Enrich & Filter` block above hints at the same fan-in mechanism: for post-loop convergence, use `entryConditions.criteria[{ type: "loop_completion" }]` with `onCriteriaFail: "wait"` — do not use `scope` as the runtime wait mechanism.
Update an existing workflow.
## Recommended flow for agent-driven edits
For editing an existing workflow step-by-step, prefer the per-step tools — they catch errors incrementally and avoid the bulk-JSON vocabulary traps (`ai` / `integration` / `knowledge_graph_query`, silently-stripped root fields like `prompt` / `listKey` / `appId`):
- `update_step({ workflowId, stepId, updates })` — change one step (prompt, inputs, next, etc.). Safest and most common.
- `add_step({ workflowId, step, insertAfter? })` — append or insert a new step.
- `remove_step({ workflowId, stepId })` — delete a step and re-wire its neighbors.
- After a series of edits: `validate_workflow` → (if live) `promote_draft` / `discard_draft`.
The bulk `updates` param below is supported for **imports, templated rewrites, and programmatic round-trips** (export → edit JSON → re-import). Agents editing interactively should not use it for step changes — use `update_step` instead.
## Trigger type guidance
Prefer `schedule` (polling) for email intake, document processing, and any workflow where exact-millisecond latency is not required — it is idempotent, supports backfill, and needs no webhook infrastructure. Use `app_event` or `webhook` only when the user explicitly says "as soon as", "within X seconds", or "real-time". When in doubt, schedule wins.
## Draft routing (live workflows)
If the workflow is live, config edits (steps, context, name, etc.) are automatically routed to a draft snapshot instead of modifying the live pipeline. The response will include `editingDraft: true`. Use `get_draft` to view the draft, `promote_draft` to make it live, or `discard_draft` to throw away the changes. Non-live workflows are updated directly with an automatic pre-edit snapshot for rollback.
## Bulk updates param (imports / round-trips only)
⚠️ Avoid sending a full `steps` array for large workflows — use `update_step` instead.
Sending more than ~20 steps risks silent truncation at the MCP transport layer.
Full steps array replacement is only safe when doing a complete pipeline replacement from a known-good JSON source (import, template, export round-trip). For editing individual steps, always use `update_step`.
## `context` merge semantics (read before patching)
**Root level:** `updates.context` is **shallow-merged** with the stored workflow’s `context` (`{ ...existingContext, ...patchContext }`). Sibling keys at the root (`inputPages`, `outputPages`, `executionInputConfig`, etc.) do not clobber each other: **omitting a key preserves the stored value**; only keys present in the patch are overwritten. To clear a collection explicitly, send an empty value (e.g. `inputPages: []`). Silent deletion-by-omission no longer applies at the root — same spirit as `update_step`’s deep-merge for nested step fields.
**One level down:** Each **value** under `context` is still replaced **wholesale** when the patch includes that key. For example, `context: { executionInputConfig: { someKey: "x" } }` replaces the entire `executionInputConfig` object — any sibling fields under it (e.g. `defaults`, `fields`, `internal`) that are not in the payload are dropped. To partial-patch a nested object, **`get_workflow` first**, merge the current value with your changes client-side, then send the **full merged** object for that key in `update_workflow`.
**Surgical alternative (preferred for context/metadata):** `update_workflow_context` is the workflow-level analog of `update_step` — it accepts the same three explicit verbs (`updates` / `replace` / `unset`) on workflow-relative paths under `context.<anything>` (both page schemas like `context.inputPages` AND user-saved page values like `context.outreachProfile`) and `metadata`. Returns `diff` + `warnings`. Use it instead of bulk `update_workflow` for any context or metadata edit, including pre-filling configuration input pages programmatically (e.g. `updates: { context: { outreachProfile: { name: "Alberto", signature: "..." } } }`). To flip a single nested key like `executionInputConfig.internal`, fetch with `get_workflow` first, merge locally, then `updates: { context: { executionInputConfig: {...full merged...} } }, replace: ["context.executionInputConfig"]` — the same merge-order trap as `update_step` applies (deep-merge runs before replace[], so replace at the parent level).
## Internal-only workflows
Set `context.executionInputConfig.internal: true` to mark a workflow as a child / sub-workflow that runs only via `agentled.call-workflow`. The UI hides the Run button and replaces the manual run form with a banner; orchestrators still pass inputs via `executionInputData` (UI guard, not runtime restriction). Use for child workflows that end in a `return` step. To toggle on an existing workflow, fetch the current value with `get_workflow`, then call `update_workflow_context` with the explicit ops shape replacing at the parent level: `updates: { context: { executionInputConfig: {...merged...} } }, replace: ["context.executionInputConfig"]`.
Add a new step to a workflow. **This is the recommended path for agent-authored workflows** — call `create_workflow({ name, goal })` first, then `add_step` one step at a time, then `validate_workflow` + `publish_workflow`.
Each call returns per-step validation errors immediately, so a bad step type / prompt template / missing required field is caught before the next step is built on top of it.
## KG-First — before writing prompt content into a step
Before writing an AI-step prompt that contains workspace-specific content (thesis, ICP criteria, scoring rubric, sector list, geo focus, brand voice, seed lists), check whether that content already lives in the workspace KG: call `list_memories` / `list_knowledge_lists` / `get_knowledge_text`. If it doesn't exist yet, seed it first (`store_memory` / `upsert_knowledge_text`) before adding this step. Then reference it at runtime in the prompt template via `{{steps.read-kg.content}}` rather than pasting the text inline.
**Boundary: strategy → KG; execution wiring → workflow context; workflow structure → workflow.**
## Required `step` fields (all types)
- `id`: stable string unique within the workflow.
- `type`: one of the closed list — `trigger`, `appAction`, `aiAction`, `aiActionWithTools`, `toolAction`, `code`, `knowledgeSync`, `return`, `milestone`, `share`, `wait`, `branch`, `parallel`, `loop`, `end_if`, `agentOrchestrator`. Any other string is silently stripped by the runtime.
- `name`: human-readable label.
Non-terminal steps also need `next: { stepId }` pointing to the next step. Terminal steps (`milestone`, `return`) omit `next`.
## Minimal shape by type
```json
// trigger (manual)
{ "id": "start", "type": "trigger", "name": "Manual Start", "pipelineStepStartConditions": { "trigger": { "type": "manual" } }, "next": { "stepId": "next-step" } }
// aiAction — LLM prompt → structured JSON
{ "id": "analyze", "type": "aiAction", "name": "Analyze",
"pipelineStepPrompt": { "template": "Analyze {{input.company_url}}", "responseStructure": { "summary": "string", "score": "number (0-100)" } },
"creditCost": 10, "next": { "stepId": "next-step" } }
// appAction — call an app/integration action
{ "id": "enrich", "type": "appAction", "name": "Enrich Company",
"app": { "id": "agentled", "actionId": "agentled.get-linkedin-company-from-url", "source": "native" },
"stepInputData": { "profileUrls": "{{input.company_url}}" },
"next": { "stepId": "next-step" } }
// → call `get_app_actions({ appId })` FIRST to get valid actionId + input field names for this app.
// aiActionWithTools — LLM agent invoking runtime tools
{ "id": "research", "type": "aiActionWithTools", "name": "Research",
"tools": [{ "type": "builtin", "name": "web_search", "builtinType": "web_search" }],
"pipelineStepPrompt": { "template": "Research {{input.topic}}", "responseStructure": { "summary": "string" } },
"creditCost": 10, "next": { "stepId": "next-step" } }
// → call `list_models` for valid builtinType values (web_search, workspace_memory, kg_search, …).
// knowledgeSync — persist prior step output to a KG list
{ "id": "save", "type": "knowledgeSync", "name": "Save to KG",
"knowledgeSync": { "source": { "stepId": "analyze", "resultsPath": "items" }, "listKey": "scored_companies", "fieldMapping": { "name": "name", "score": "score" } },
"next": { "stepId": "done" } }
// milestone — terminal step for top-level workflows
{ "id": "done", "type": "milestone", "name": "Done" }
```
## Variable references
- `{{input.fieldName}}` — input page field (defined in `context.executionInputConfig.fields` or `context.inputPages[].configuration.fields`).
- `{{steps.stepId.fieldName}}` — output of a prior step.
- `{{currentItem.field}}` — current item inside a `loopConfig` iteration.
Trigger step inputs are referenced as `{{input.X}}`, **not** `{{steps.trigger-id.X}}` — common agent mistake.
## Composable step blocks
When building multi-step workflows, apply these reusable patterns:
- **Search & Extract**: aiAction (generate queries) → appAction (search) → aiAction (extract). Never pass raw input to search APIs.
- **Enrich & Score**: appAction (fetch data) → aiAction (score). Always enrich before scoring.
- **Draft & Send**: aiAction email → approval with onApproval.action="schedule-email". Use HTML bodies for open/click tracking. Do not use Gmail/Outlook send appActions unless explicitly requested.
- **Report & Notify**: aiAction report with Config renderer → share step → aiAction notification email. Include `{{steps.<shareStepId>.shareUrl}}` in the email template and keep the body to an HTML overview + report link.
- **Scrape & Summarize**: appAction (scrape) → aiAction (summarize).
- **Loop Enrich & Filter**: loopConfig on first step only → appAction (enrich each) → aiAction (score/filter). Post-loop: aiAction to rank with `entryConditions.criteria[{ type: "loop_completion" }]` and `onCriteriaFail: "wait"`. Do not use `scope` as the runtime wait/fan-in mechanism; it only declares explicit container membership.
- **Multi-phase KG pipeline**: each phase reads its input status, processes rows, then advances them (kg.upsert-rows with initial status → kg.read-list by status → kg.update-rows to next status). Status values are user-defined per pipeline. Status is DB-indexed — always filter by a single equality value, never scan. Mark the next status BEFORE side-effects (email, share, Slack).
(Source of truth: COMPOSABLE_STEP_BLOCKS in workflowPatternExamples.ts)
## Positioning
Use `insertAfter` to place the step after an existing step ID. When `rewireNext` is true (default), the insertAfter step's `next` is updated to the new step, and the new step's `next` is set to what insertAfter previously pointed to — maintaining the chain. Validates step ID uniqueness. Respects draft snapshot routing for live workflows.
Update a single step in a workflow by step ID. **Preferred path for any single-step edit on an existing workflow** — only the fields in `updates` / `replace` / `unset` are touched, every other step and field is left as-is.
Use this instead of `update_workflow` for any one-step change (prompt, inputs, entry conditions, switching shape, swapping tools). `update_workflow` with a full `steps` array is for imports/round-trips only.
## KG-First — when editing a prompt template
If this edit introduces or changes workspace-specific content in a prompt template (thesis, ICP, rubric, sector list, geo focus, brand voice), check the KG first: call `list_memories` / `list_knowledge_lists` / `get_knowledge_text`. Seed the content there if it isn't already present, then reference it at runtime (`{{steps.read-kg.content}}`) instead of pasting text inline.
**Boundary: strategy → KG; execution wiring → workflow context; workflow structure → workflow.**
## Merge semantics
`update_step` accepts three independent operations on the same call. At least one must be non-empty.
- **`updates`** — partial step patch. Top-level fields are replaced; nested objects (`pipelineStepPrompt`, `stepInputData`, `entryConditions`, `renderer`, etc.) are deep-merged ONE LEVEL deep — keys nested two levels deep are overwritten as a unit, not merged. Arrays are replaced wholesale.
- **`replace: string[]`** — dot-paths (e.g. `"stepInputData.fieldUpdates"`) whose values from `updates` are assigned WHOLESALE onto the step, skipping the deep-merge. Use for dictionary-shaped fields where keys are user data.
- **`unset: string[]`** — dot-paths to DELETE. Each must exist on the original step.
## When to use which
| Situation | Verb | Example |
|---|---|---|
| Change one config key, keep siblings | `updates` | `updates: { pipelineStepPrompt: { template: "new..." } }` keeps `responseStructure` |
| Add a stepInputData entry | `updates` | `updates: { stepInputData: { profileUrls: "{{input.url}}" } }` |
| Replace a dictionary wholesale (keys = user data) | `replace` | `updates: { stepInputData: { fieldUpdates: {...} } }, replace: ["stepInputData.fieldUpdates"]` |
| Replace `responseStructure` / `knowledgeSync.fieldMapping` | `replace` | `replace: ["pipelineStepPrompt.responseStructure"]` |
| Remove a step input | `unset` | `unset: ["stepInputData.oldKey"]` |
| Swap full arrays (tools, integrations) | `updates` | `updates: { tools: [...] }` (arrays already replaced wholesale) |
**The trap.** Default deep-merge is one level deep — patching `stepInputData.fieldUpdates` with a partial dict silently wipes the others. Either send the FULL dict + `replace: ["stepInputData.fieldUpdates"]`, or call `get_step` first, edit locally, send back via `replace`.
## Read-before-write for dictionary fields
For dictionary fields where keys are user data (`stepInputData.fieldUpdates`, `responseStructure`, `fieldMapping`): `get_step` (~1KB), modify locally, send full object back under `replace[]`.
## Diff + warnings
Response includes `diff: { addedPaths, changedPaths, removedPaths }` and `warnings[]`. ≥6 fields removed without explicit `unset` triggers a warning — usually a "you wiped a dictionary" signal.
## Shape conversions
Fetch the canonical example before changing shape:
- email: `get_step_schema({ stepType: "aiAction", shape: "email" })`
- report: `get_step_schema({ stepType: "aiAction", shape: "report" })`
Send under `updates`, `replace[]` for dictionary children, `unset[]` for stale type-specific fields.
## Won't do
- **Cannot change `step.id`** — root id is immutable; API returns 400. Nested `*.id` is fine.
- **Does not enforce `step.type` immutability.** Stale type-specific fields (`pipelineStepPrompt`, `app`, `tools`) persist unless `unset`. For clean conversions, prefer `remove_step` + `add_step`.
- **Does not validate the merged result against shape rules** — call `validate_workflow` after edits.
## Draft routing (live workflows)
Edits are routed to a draft snapshot (`editingDraft: true` in response). Inspect via `get_draft`, ship via `promote_draft`, discard via `discard_draft`.
When a draft exists, the response carries a `draft` summary: `{ exists, draftCreatedAt, liveUpdatedAt, stale, modifiedStepIds, modifiedFields }`. **If `draft.stale === true`, the live workflow advanced past the draft** — promoting will land older values for fields you didn't touch. A staleness warning is pushed into `warnings[]`. Recovery: `discard_draft` + retry, or inspect via `get_draft` first.
Surgical edit of `workflow.context` (and `workflow.metadata`) — the workflow-level analog of `update_step`. Does NOT reach into `steps` (use `update_step` for that).
## Calling shape (preferred): three explicit verbs
`{ updates, replace, unset }` — same merge model as `update_step`, but on **workflow-relative paths**. At least one must be non-empty.
- **`updates`** — partial workflow patch. Top-level keys are limited to `context` and `metadata`. Each is shallow-merged (`{ ...stored, ...patch }`) so omitting a sibling preserves it. Direct sub-objects under `context` (e.g. `executionInputConfig`) are still replaced wholesale by default — use `replace[]` for explicit deep replacement, `unset[]` for deletion.
- **`replace: string[]`** — workflow-relative dot-paths whose values from `updates` are assigned WHOLESALE, skipping the deep-merge. Path examples: `"context.executionInputConfig.fields"`, `"context.inputPages"`, `"metadata.tags"`. The path's value MUST be present in `updates`.
- **`unset: string[]`** — workflow-relative dot-paths to delete. Each must currently exist on the workflow.
## What lives under `context`
`workflow.context` holds two things, side by side as siblings:
1. **Page schemas** — `context.inputPages`, `context.outputPages`, `context.executionInputConfig`. Field definitions, defaults, shortDescriptionFields, etc.
2. **User-saved page values** — `context.<contextKey>`, where `<contextKey>` mirrors a page's `contextKey`. e.g. `context.outreachProfile`, `context.cadence`, `context.introductionWorkflow`. These are the values a user persists when clicking "Save" on a configuration input page.
### Page entry shapes (read these BEFORE writing to `context.outputPages` or `context.inputPages`)
The workflow detail UI crashes on load if a page entry is missing required fields, and `validate_workflow` now rejects bad shapes with `MISSING_OUTPUT_PAGE_FIELD` / `INVALID_OUTPUT_STEPS_TYPE`.
- **`context.outputPages`** — `PipelineOutputPage[]`. Authoritative example: `get_step_schema({ stepType: "outputPage", shape: "standard" })`.
- Required: `id` (string, unique), `title` (string), `pathname` (string, URL slug), `outputSteps` (string[] of step IDs that exist in `workflow.steps`).
- Optional: `description`, `iconName`, `displayConfig.showExecutionsList` (boolean), `displayConfig.executionNameTemplate`, `displayConfig.filterStatuses`, `displayConfig.defaultFilterStatus`, `displayConfig.sortField`, `displayConfig.sortDirection`.
- Product rule: choose only the 1-3 user-facing result surfaces that match the workflow, e.g. LinkedIn publish, X/Twitter publish, scheduled email/outreach, report, or canonical results list. Group related step outputs on one page. Do not create output pages for approval placeholders, status markers, or internal implementation details.
- **`context.inputPages`** — `PipelineInputPage[]`. Authoritative example: `get_step_schema({ stepType: "inputPage", shape: "standard" })`.
- Required: `title`, `pathname`, `configuration.contextKey`, `configuration.fields[]`. Saved values land at `context.<contextKey>` (sibling).
Both shapes are dictionaries the workflow author owns, both are read at runtime via `{{context.<key>.<field>}}`, and both are edited through this tool with the same three-verb model. To pre-fill a config page programmatically:
```jsonc
update_workflow_context({
workflowId,
updates: { context: { outreachProfile: { name: "Alberto", signature: "<p>Best, Alberto</p>" } } }
})
```
Sibling context keys are preserved by the one-level deep-merge. Live workflows route to draft.
## Allowed path scope (both replace and unset)
Paths must begin with one of:
- `context.<anything>` — page schemas (`context.inputPages`, …) or user-saved page values (`context.outreachProfile`, `context.cadence`, …).
- `metadata` (exact, or any `metadata.*` sub-path).
Anything else (e.g. `steps.*`, `name`, `goal`, `status`) is rejected with `PATH_OUT_OF_SCOPE`. Use `update_step` for step-level edits and `update_workflow` for top-level scalars (`name`, `goal`, `description`, `style`).
## Diff and warnings
The ops shape returns `diff: { addedPaths, changedPaths, removedPaths }` and `warnings[]`. If ≥6 fields were silently removed without an explicit `unset`, a warning fires — that's usually a "you wiped a dictionary" signal. Read it.
## Errors (400)
| Code | When |
|---|---|
| `EMPTY_PAYLOAD` | All three of `updates`/`replace`/`unset` are missing or empty. |
| `INVALID_PATH` | Dot-path syntax violation (empty segment, leading/trailing dot, prototype-pollution segment). |
| `PATH_OUT_OF_SCOPE` | Path is not under `context.<anything>` or `metadata` (e.g. `steps.*`, `name`). |
| `REPLACE_VALUE_MISSING` | A `replace[]` path has no corresponding value in `updates`. |
| `UNSET_PATH_NOT_FOUND` | An `unset[]` path doesn't exist on the workflow. |
## Draft routing (live workflows)
Context edits are routed to a draft snapshot (`editingDraft: true`). Metadata is NOT part of the snapshot config — metadata edits write directly to the Pipeline row, **immediately and on the live workflow**.
⚠ **Mixed metadata + context in one call**: metadata is applied immediately while context goes to the pending draft. `discard_draft` reverts the pending context changes but **does NOT revert metadata**. If you need a single atomic checkpoint covering metadata too, call `create_snapshot` first, or split the call.
## Compatibility body shape
A legacy `{ contextKey, value }` shape is still accepted for one-shot wholesale replacement of a single root context key (`inputPages` / `outputPages` / `executionInputConfig` only — saved-values keys are not reachable through this shape). It does not return `diff` / `warnings` and cannot edit metadata. Prefer the three-verb shape above for new code.
## Recipes
```jsonc
// Add a single field to executionInputConfig.fields without rebuilding the array.
// Step 1: get_workflow → read context.executionInputConfig.fields
// Step 2:
{
updates: { context: { executionInputConfig: {...full new value with the appended field...} } },
replace: ["context.executionInputConfig"]
}
// Replace inputPages wholesale.
{
updates: { context: { inputPages: [...new pages...] } },
replace: ["context.inputPages"]
}
// Pre-fill a config page (user-saved values land at context.<contextKey>).
// Uses one-level deep-merge under updates.context — sibling saved-values
// dictionaries are preserved.
{
updates: { context: { outreachProfile: { name: "Alberto", signature: "<p>Best, Alberto</p>" } } }
}
// Wholesale-replace a single saved-values dictionary.
{
updates: { context: { cadence: { firstNudgeDays: 3, secondNudgeDays: 7 } } },
replace: ["context.cadence"]
}
// Delete a saved-values dictionary.
{ unset: ["context.introductionWorkflow"] }
// Add a metadata tag.
{ updates: { metadata: { tags: ["beta"] } } }
// Save an operator-facing executive summary for a workflow or workflow group.
// Use this when a user asks to save a summary for the workflow, cluster, group,
// or home card. Store it in metadata, not as KG text, unless the user explicitly
// asks for a reusable knowledge note. For groups, write once to the owner
// pipeline: prefer workflowGraph.role === "orchestrator"; otherwise use the
// lowest workflowGraph.order pipeline. Keep body short, include concrete metrics
// and the reporting period when available, and set author to the active
// workspace agent name only (for example "AngelHive Assistant"), not the
// external coding/tool agent, and without a leading "by".
{
updates: {
metadata: {
executiveSummary: {
body: "Startup Outreach sent 46 founder emails for the reporting period, with 28 opens and 9 clicks: a 60.9% open rate, 19.6% click rate, and 32.1% click-to-open rate.",
bullets: ["Clicks: 6 UTM Pitch Night, 2 plain Pitch Night, 1 calendar."],
generatedAt: "2026-06-03T00:00:00.000Z",
author: "AngelHive Assistant"
}
}
}
}
// Delete an obsolete metadata key.
{ unset: ["metadata.legacyFlag"] }
// Toggle executionInputConfig.internal: fetch first (get_workflow), merge locally, replace at the
// PARENT level. The one-level deep-merge under updates.context wipes nested-object siblings BEFORE
// replace[] runs (same merge-order trap as update_step) — so replace at "context.executionInputConfig"
// (not ".internal") and pass the full object in updates.
{
updates: { context: { executionInputConfig: {...full merged executionInputConfig with internal: true...} } },
replace: ["context.executionInputConfig"]
}
```
Response: `{ editingDraft?, context, metadata?, diff?, warnings?, validation }`.