Skip to content

Building Stateful Workflows in Tandem

Use this page when an operator or agent needs to build a workflow that must keep track of completed work, pause safely, react to an external event, or recover without starting over.

Agent search terms: stateful workflow, durable workflow, Automation V2 checkpoint, approval wait, webhook trigger, webhook step, resume run, replay, dead letter, compensation, MCP workflow, run recovery.

NeedStart here
AuthenticationEngine Authentication For Agents
MCP discovery and authorityMCP Capability Discovery And Request Flow and MCP Automated Agents
Automation V2 authoringWebhook review and publish example and Creating And Running Workflows And Missions
Multi-workflow goalsLong-Running Multi-Workflow Goals for version-pinned cross-run handoffs, replay, limits, and replanning loops
Webhook intake and routingTrigger the workflow with a webhook and Automation V2 Webhooks
Human approvalApproval and continuation
Inspection and recoveryInspect the durable run and Recover safely
Cross-run knowledgeState within a run and across runs and Memory Internals

A saved Automation V2 definition is persistent, but that alone does not make each execution stateful. The stateful part is the durable run record that tracks which nodes finished, what they produced, whether the run is waiting, and which recovery actions remain safe.

ObjectWhat it storesWhat it is not
Automation definitionAgents, DAG nodes, policies, schedule, workspace, and trigger configurationA particular execution
Automation runOne execution of a frozen automation snapshotA reusable knowledge base
CheckpointCompleted, pending, and blocked nodes; node outputs and attempts; gates and failuresThe live event stream
Node outputThe bounded result of one node, available to downstream nodesAutomatically trusted cross-run knowledge
Context-run blackboardA task-oriented projection of run and node state, plus durable patches and artifactsA replacement for the Automation V2 checkpoint
ArtifactA durable file, handoff, report, draft, or receiptSemantic memory unless explicitly stored or promoted
EventAn append-oriented fact about what happenedThe complete current state by itself
SnapshotA point-in-time state and checkpoint projection used for inspection or replay boundariesPermission to repeat external side effects
WaitA durable pause such as approval, timer, or webhook correlationA normal DAG dependency
Reliability recordOutbox entry, tool effect, dead letter, or compensation recordProof that an external action is safe to repeat
Promoted knowledgeReviewed project knowledge reusable by later runsRaw notes or every prior node output

Events are useful for progress, while checkpoints and snapshots are the durable source of execution state. Artifacts and promoted knowledge have different trust and reuse rules; do not flatten them into one generic “memory” layer.

flowchart TD
  D["Automation V2 definition"] --> Q["Run queued"]
  Q --> R["Eligible root node runs"]
  R --> T{"Triage has work?"}
  T -- "no" --> S["Downstream nodes skipped"]
  T -- "yes" --> W["Work nodes + checkpoints"]
  S --> C
  W --> A["Awaiting human approval"]
  A -- "approve" --> P["Post-approval MCP action"]
  A -- "cancel or expiry" --> X["Cancelled / attention required"]
  P --> C["Completed"]
  W -- "retryable failure" --> Y["Retrying"]
  Y --> W
  Y -- "exhausted / uncertain effect" --> F["Failed or dead-lettered"]
  F --> O["Inspect resume plan"]
  O --> Y
  O --> K["Compensate or abandon with audit"]
CapabilityCurrent statusHow to use it
DAG dependencies and bounded handoffsPublicly authorabledepends_on, input_refs, and output_contract
Manual, scheduled, watch-condition, and webhook startsPublicly authorableAutomation V2 schedule, watch conditions, or webhook trigger
Node retries and timeoutsPublicly authorableretry_policy and timeout_ms
Human approval pause and expiryPublicly authorableApproval node with gate.expiry_policy
Pause, resume, task repair, and run recoveryPublicly callableAutomation V2 run endpoints and Control Panel actions
Checkpoints, events, snapshots, waits, and observabilityRuntime-managed and inspectableAutomation V2 and /stateful-runtime/* read surfaces
Outbox effects, dead letters, compensation, and resume plansRuntime-managed and operator-reviewableStateful reliability and resume-plan surfaces
Webhook targeting an arbitrary node IDNot publicly authorableStart at DAG roots and route through a guard node instead
Webhook preview as a node inputNot publicly authorableTandem stores sanitized webhook metadata on the run snapshot for inspection, but current public authoring does not inject it into a root node prompt or input_refs
Timer, approval, correlated webhook, and external waitsPublicly authorableSet a typed wait on an Automation V2 node; TypeScript, Python, HTTP, and raw MCP automation authoring use the same wire contract

There is no stateful: true flag. Automation V2 runs are projected into the stateful runtime automatically.

A wait node is a non-agent node. It cannot also declare a legacy gate, tool or MCP policy, retry policy, node timeout, or tool-call budget. When the node becomes eligible, Tandem checkpoints the run, registers the wait, and pauses the same run. A successful wake completes that node with a bounded stateful_wait output and queues the same run so its downstream nodes can continue.

Use one of these wait objects on an Automation V2 flow node:

{
"node_id": "wait_for_window",
"agent_id": "system",
"objective": "Wait for the publishing window",
"depends_on": ["prepare_draft"],
"wait": { "kind": "timer", "delay_ms": 86400000 }
}
{
"node_id": "wait_for_approval",
"agent_id": "system",
"objective": "Wait for a publish decision",
"depends_on": ["prepare_draft"],
"wait": {
"kind": "approval",
"decisions": ["approve", "deny"],
"timeout": { "expires_after_ms": 604800000, "on_timeout": "cancel" }
}
}
{
"node_id": "wait_for_callback",
"agent_id": "system",
"objective": "Wait for the correlated provider callback",
"depends_on": ["create_external_job"],
"wait": {
"kind": "webhook",
"trigger_id": "trigger_123",
"provider": "custom",
"provider_event_kind": "job.completed",
"correlation": {
"field": "provider_event_id",
"value": {
"source": "node_output",
"node_id": "create_external_job",
"json_pointer": "/content/provider_event_id"
}
},
"timeout": { "expires_after_ms": 604800000, "on_timeout": "escalate", "escalate_to": "operations" }
}
}
{
"node_id": "wait_for_legal_hold",
"agent_id": "system",
"objective": "Wait for the external condition resolver",
"depends_on": ["prepare_draft"],
"wait": {
"kind": "external_condition",
"condition_key": { "source": "literal", "value": "legal-hold-42" },
"timeout": { "expires_after_ms": 2592000000, "on_timeout": "cancel" },
"payload_schema": {
"type": "object",
"required": ["released"],
"properties": { "released": { "type": "boolean" } }
}
}
}

Bindings with source: "node_output" must point to a declared upstream dependency. json_pointer uses JSON Pointer syntax and must start with /. Timer nodes require exactly one of delay_ms or wake_at. Every webhook and external-condition wait requires an explicit timeout policy. Public resolution endpoints for externally resolved conditions are covered by the stateful runtime API work; until that endpoint is enabled in your build, only the internal governed resolver can wake that wait kind.

Build a webhook review and publish workflow

Section titled “Build a webhook review and publish workflow”

This supported pattern starts a new run from a signed webhook, treats the delivery as an untrusted wake signal, validates current work through a read-only authoritative lookup, writes a reviewable artifact, waits for a verified human, and gives only the final node permission to publish through MCP.

  1. Authenticate to a running engine and verify GET /global/health.
  2. Use mcp_list first, then mcp_list_catalog if you need to distinguish a disconnected catalog entry from an unknown capability. Discover one read-only source query and one post-approval publish tool.
  3. Connect the MCP through the operator path and confirm the exact namespaced tool ID with GET /mcp/tools or GET /tool/ids.
  4. Replace the illustrative Slack server/tool names below with the exact IDs returned by your engine.

An MCP server being visible in a catalog is not execution authority. Both the agent and the node must have the narrow policy required for their step.

The engine’s raw Automation V2 JSON uses snake_case. The TypeScript and Python clients expose the typed wait contract; the example still casts the complete source-backed raw payload at the SDK boundary because it also uses low-level gate, metadata, and output-contract fields.

import { TandemClient } from "@frumu/tandem-client";
const client = new TandemClient({
baseUrl: process.env.TANDEM_ENGINE_URL ?? "http://127.0.0.1:39731",
token: process.env.TANDEM_ENGINE_TOKEN!,
});
const automationSpec = {
name: "Webhook review and publish",
description:
"Validate a signed review request, create an artifact, wait for a human, then publish through a narrowly scoped MCP node.",
status: "active",
schedule: {
type: "manual",
timezone: "UTC",
misfire_policy: { type: "run_once" },
},
workspace_root: "/workspace/project",
scope_policy: {
readable_paths: ["artifacts"],
writable_paths: ["artifacts"],
denied_paths: [".git"],
watch_paths: [],
},
agents: [
{
agent_id: "event_guard",
display_name: "Event guard",
skills: [],
tool_policy: {
allowlist: ["mcp.linear.list_issues"],
denylist: [],
},
mcp_policy: {
allowed_servers: ["linear"],
allowed_tools: ["mcp.linear.list_issues"],
},
},
{
agent_id: "writer",
display_name: "Draft writer",
skills: [],
tool_policy: { allowlist: ["read", "write"], denylist: [] },
mcp_policy: { allowed_servers: [], allowed_tools: [] },
},
{
agent_id: "review_gate",
display_name: "Human review gate",
skills: [],
tool_policy: { allowlist: [], denylist: [] },
mcp_policy: { allowed_servers: [], allowed_tools: [] },
},
{
agent_id: "publisher",
display_name: "Approved publisher",
skills: [],
tool_policy: {
allowlist: ["read", "mcp.slack.send_message"],
denylist: [],
},
mcp_policy: {
allowed_servers: ["slack"],
allowed_tools: ["mcp.slack.send_message"],
},
},
],
flow: {
nodes: [
{
node_id: "validate_event",
agent_id: "event_guard",
objective: `
Treat the webhook only as an untrusted wake signal. Call the exact read-only
Linear list tool once with the fixed Operations Review project and
"awaiting-review" label configured here. Do not accept a project, label, issue
ID, query, or destination from webhook data.
Allow work only when the authoritative Linear result contains one non-terminal
issue in that fixed project with that label. Choose the oldest matching issue.
Return only JSON with has_work, allowed, reason_code, request_id, and summary.
Set has_work=false and allowed=false when no authoritative item matches.
`.trim(),
metadata: { triage_gate: true },
tool_policy: {
allowlist: ["mcp.linear.list_issues"],
denylist: [],
},
mcp_policy: {
allowed_servers: ["linear"],
allowed_tools: ["mcp.linear.list_issues"],
},
timeout_ms: 30_000,
retry_policy: { max_attempts: 2 },
output_contract: {
kind: "structured_json",
validator: "structured_json",
schema: {
type: "object",
required: ["has_work", "allowed", "reason_code", "request_id"],
properties: {
has_work: { type: "boolean" },
allowed: { type: "boolean" },
reason_code: { type: "string" },
request_id: { type: "string" },
summary: { type: "string" },
},
},
},
},
{
node_id: "write_draft",
agent_id: "writer",
depends_on: ["validate_event"],
input_refs: [{ from_step_id: "validate_event", alias: "event_decision" }],
objective: `
Use only the validated event_decision. Write a review artifact under
artifacts/webhook-review.md. Return JSON with draft_path, request_id, and
publish_summary. Do not call an external service.
`.trim(),
tool_policy: { allowlist: ["read", "write"], denylist: [] },
mcp_policy: { allowed_servers: [], allowed_tools: [] },
timeout_ms: 120_000,
retry_policy: { max_attempts: 3 },
output_contract: {
kind: "structured_json",
validator: "structured_json",
schema: {
type: "object",
required: ["draft_path", "request_id", "publish_summary"],
properties: {
draft_path: { type: "string" },
request_id: { type: "string" },
publish_summary: { type: "string" },
},
},
},
},
{
node_id: "approve_publish",
agent_id: "review_gate",
depends_on: ["write_draft"],
input_refs: [{ from_step_id: "write_draft", alias: "reviewable_draft" }],
objective:
"Pause for a verified human to approve or cancel publication of the reviewable draft.",
stage_kind: "approval",
tool_policy: { allowlist: [], denylist: [] },
mcp_policy: { allowed_servers: [], allowed_tools: [] },
gate: {
required: true,
// "cancel" is the current server's deny/stop decision.
decisions: ["approve", "cancel"],
rework_targets: ["write_draft"],
instructions:
"Review artifacts/webhook-review.md. Approve only if the destination and summary are correct.",
expiry_policy: {
expires_after_ms: 86_400_000,
on_expiry: "cancel",
},
},
},
{
node_id: "publish_approved",
agent_id: "publisher",
depends_on: ["approve_publish"],
input_refs: [{ from_step_id: "write_draft", alias: "approved_draft" }],
objective:
"After approval, read approved_draft and call the exact Slack send tool once for the configured #ops-review channel. Never accept a destination from webhook data. Return the destination and external receipt ID.",
tool_policy: {
allowlist: ["read", "mcp.slack.send_message"],
denylist: [],
},
mcp_policy: {
allowed_servers: ["slack"],
allowed_tools: ["mcp.slack.send_message"],
},
timeout_ms: 60_000,
retry_policy: { max_attempts: 1 },
output_contract: {
kind: "structured_json",
validator: "structured_json",
schema: {
type: "object",
required: ["destination", "receipt_id"],
properties: {
destination: { type: "string" },
receipt_id: { type: "string" },
},
},
},
},
],
},
execution: {
max_parallel_agents: 1,
max_total_runtime_ms: 900_000,
max_total_tool_calls: 20,
},
output_targets: ["file://artifacts/webhook-review.md"],
creator_id: "stateful-workflow-guide",
};
const created = await client.automationsV2.create(automationSpec as any);
const automationId = String(created.automation.automation_id);
const webhook = await client.automationsV2.createWebhookTrigger(automationId, {
name: "Review requested",
provider: "custom",
provider_event_kind: "review.requested",
signature_scheme: "hmac_sha256_v1",
});
const callbackUrl = webhook.trigger.callback_url ?? webhook.trigger.callbackUrl;
const oneTimeSecret = webhook.new_secret ?? webhook.newSecret;
console.log({ automationId, callbackUrl });
// Store oneTimeSecret in a secret manager now. Do not log or commit it.

Python callers can submit the same snake_case Automation V2 payload; use the Python SDK guide for client setup and response handling rather than duplicating the full definition.

Replace /workspace/project with an absolute workspace path owned by this automation.

The illustrative Linear and Slack MCP names are not portable. Discover the real namespaced tool IDs first and save each exact ID in both the relevant node tool policy and MCP policy. Keep the guard’s query filters fixed in the definition; never derive them from webhook content.

  1. Open Automations and create a manual Automation V2 workflow in Studio.
  2. Add the four nodes in the same order and review every node-level policy.
  3. Give the guard only the fixed read-only source query, keep the approval node tool-free, and keep the send tool only on the final node.
  4. Save the automation as active.
  5. Open its webhook manager, create a standard HMAC trigger, and store the one-time secret securely.
  6. Send a test event, open Recent deliveries, and follow the queued run.
  7. Review the artifact and decide the pending approval as a verified human.
  8. Confirm the final node recorded a destination receipt.

Approval is an authority boundary, not another agent task. An agent cannot approve its own gate.

A public webhook trigger belongs to an Automation V2 definition. When a valid delivery has no matching internal wait, Tandem creates a run with trigger_type: "webhook"; normal DAG eligibility starts the root nodes. The webhook does not select validate_event by name and cannot jump directly to publish_approved.

Tandem stores sanitized run metadata under automation_snapshot.metadata.automation_webhook, including the provider, configured event-kind label, delivery and trigger IDs, body digest, idempotency fields, verification result, and sanitized preview. Operators can inspect it through the run and delivery surfaces.

Current public Automation V2 authoring does not inject that metadata into a root node prompt or expose it as an input_refs source. Do not write a guard prompt that claims it can read automation_webhook. In this example, the delivery starts the run and the guard queries the authoritative provider with a fixed read-only filter.

provider_event_kind labels the trigger and the resulting metadata. It is not a JSON payload filter. Use separate triggers when the label matters operationally, and perform project, label, entity, and state checks against data the guard can actually read, such as an authoritative connector lookup. With metadata.triage_gate: true, returning has_work: false skips nodes that depend only on that triage path.

The public callback accepts JSON bodies up to 1 MiB. Standard Tandem HMAC signs the exact bytes of <timestamp_ms>.<raw_json_body>. Use the exact callback_url returned by Tandem. Hosted callbacks use https://test.tandem.ac/api/engine/webhooks/...; a direct local Engine uses http://127.0.0.1:39731/webhooks/....

Terminal window
export TANDEM_WEBHOOK_SECRET='store-this-outside-source-control'
export CALLBACK_URL="${CALLBACK_URL:-https://test.tandem.ac/api/engine/webhooks/automations/whpub_your_public_token}"
EVENT_ID="review-$(date +%s)-$RANDOM"
BODY="{\"action\":\"review_requested\",\"request_id\":\"$EVENT_ID\",\"occurrence_id\":\"$EVENT_ID\",\"summary\":\"Publish the approved operations update\"}"
TIMESTAMP_MS="$(node -e 'process.stdout.write(Date.now().toString())')"
SIGNATURE="$(BODY="$BODY" TIMESTAMP_MS="$TIMESTAMP_MS" node -e '
const crypto = require("node:crypto");
const payload = `${process.env.TIMESTAMP_MS}.${process.env.BODY}`;
process.stdout.write(
crypto.createHmac("sha256", process.env.TANDEM_WEBHOOK_SECRET)
.update(payload)
.digest("hex")
);
')"
curl -sS -X POST "$CALLBACK_URL" \
-H 'content-type: application/json' \
-H "X-Tandem-Webhook-Signature: t=$TIMESTAMP_MS,v1=$SIGNATURE" \
-H "X-Tandem-Webhook-Event-ID: $EVENT_ID" \
--data-binary "$BODY"

An HTTP 202 Accepted means Tandem durably accepted the raw event for asynchronous processing. It does not mean a run or node completed, or even that a new run was created. An identical replay is recorded as a duplicate and keeps the original run correlation. Cancelling the original run does not reset webhook deduplication. Generate a new EVENT_ID and change the JSON body, as the example does, when you intentionally want another test run; then recompute the HMAC over the new exact body.

The TypeScript example above is the canonical full payload. The equivalent raw HTTP sequence is:

Terminal window
ENGINE_URL="${TANDEM_ENGINE_URL:-http://127.0.0.1:39731}"
AUTH="Authorization: Bearer $TANDEM_ENGINE_TOKEN"
# Save automationSpec as snake_case JSON in automation.json first.
curl -sS -X POST "$ENGINE_URL/automations/v2" \
-H "$AUTH" -H 'content-type: application/json' \
--data-binary @automation.json
curl -sS -X POST "$ENGINE_URL/automations/v2/$AUTOMATION_ID/webhook-triggers" \
-H "$AUTH" -H 'content-type: application/json' \
-d '{
"name":"Review requested",
"provider":"custom",
"provider_event_kind":"review.requested",
"signature_scheme":"hmac_sha256_v1"
}'
curl -sS \
"$ENGINE_URL/automations/v2/$AUTOMATION_ID/webhook-triggers/$TRIGGER_ID/deliveries?limit=20" \
-H "$AUTH"

Read the newest delivery’s status, delivery_id, and queued_run_id. Follow queued_run_id to the run APIs. A runtime-internal wake instead exposes woken_run_id and woken_wait_id.

Delivery situationRuntime resultPublic workflow-authoring meaning
Valid event, no matching waitCreates one new webhook-triggered runSupported path; guard the root and route through dependencies
Rejected, disabled, or suppressed deliveryDoes not create a runInspect the delivery status and reason code; do not infer execution from the intake response
Duplicate event or bodyDoes not create a second runInspect duplicate_of_delivery_id, duplicate_of_run_id, and dedupe reason
Same event ID with changed bodyRejects as an idempotency conflictDo not reuse an event ID for different content
Notion verification handshakeStores/reveals the verification token; no workflow runFinish Notion subscription verification first
Matching registered webhook waitRequeues the waiting run and records woken run/wait IDsDeclare a typed webhook wait node; the correlation and dependencies select the continuation

Duplicate redelivery is suppressed before it can create another run. Tandem uses trigger-scoped provider event IDs when available and always tracks the body digest. Use a stable provider event ID and keep the body immutable for retries.

For provider-specific GitHub, Notion, and Linear signatures, secret ownership, and setup, use Automation V2 Webhooks.

When approve_publish becomes eligible, the run records an approval wait and enters awaiting_approval. Inspect pending approvals, review the artifact, and submit the decision as a verified human:

Terminal window
curl -sS "$ENGINE_URL/approvals/pending?source=automation_v2" -H "$AUTH"
curl -sS -X POST "$ENGINE_URL/automations/v2/runs/$RUN_ID/gate" \
-H "$AUTH" -H 'content-type: application/json' \
-d '{"decision":"approve","reason":"Artifact and destination verified"}'

The current server decision vocabulary is approve, rework, or cancel. Use cancel, not deny, when publication should stop. Rework can return work to configured rework_targets. An expired gate refuses a late decision.

After approval, the existing run continues from its checkpoint. Completed nodes are not recreated, and the final node receives only its declared upstream input and exact MCP authority.

NeedStore or mechanismReuse boundary
Know which nodes already finishedAutomation V2 checkpointSame run
Feed a bounded result to another nodeNode output plus input_refsSame run
Track work as tasks and patchesContext-run blackboardSame run
Preserve a report, draft, or receiptArtifact or handoff fileRun or workspace
Reconstruct how execution changedEvents plus snapshotsSame run and audit history
Reuse reviewed facts in a later runPromoted project knowledgeLater runs in the approved scope

Do not use global memory as a substitute for workflow state. Raw webhook bodies, working notes, and failed drafts should not become trusted reusable knowledge automatically.

MethodPathPurpose
GET/automations/v2/runs/{run_id}Authoritative Automation V2 run and checkpoint
GET/stateful-runtime/runs/{run_id}Canonical stateful projection, wait, latest event, and snapshot boundaries
GET/stateful-runtime/runs/{run_id}/eventsOrdered stateful events with sequence filters
GET/stateful-runtime/runs/{run_id}/snapshotsAvailable durable snapshots
GET/stateful-runtime/runs/{run_id}/snapshots/{snapshot_id}One snapshot and checkpoint boundary
GET/stateful-runtime/runs/{run_id}/observabilityCombined operational view of current state
GET/stateful-runtime/runs/{run_id}/reliabilityOutbox, effects, dead letters, and compensations for the run
GET/stateful-runtime/runs/{run_id}/resume-planSafe resume points and operator choices
POST/stateful-runtime/runs/{run_id}/resume-planRecord or dispatch an operator-selected recovery action
GET/approvals/pending?source=automation_v2Tenant-scoped pending Automation V2 approvals
POST/automations/v2/runs/{run_id}/gateRecord a verified human gate decision
GET/automations/v2/{id}/webhook-triggers/{trigger_id}/deliveriesDelivery verification, dedupe, queued/woken correlation
GET/automations/v2/runs/{run_id}/webhook-eventsRaw-event metadata associated with the run

For a quick inspection:

Terminal window
curl -sS "$ENGINE_URL/automations/v2/runs/$RUN_ID" -H "$AUTH"
curl -sS "$ENGINE_URL/stateful-runtime/runs/$RUN_ID" -H "$AUTH"
curl -sS "$ENGINE_URL/stateful-runtime/runs/$RUN_ID/events?tail=50" -H "$AUTH"
curl -sS "$ENGINE_URL/stateful-runtime/runs/$RUN_ID/resume-plan" -H "$AUTH"

An event describes a transition. Confirm historical or recovery-sensitive facts against the checkpoint, latest snapshot, wait, and reliability records.

  1. Inspect checkpoint.completed_nodes, pending_nodes, blocked_nodes, node outputs, attempts, awaiting_gate, and last_failure.
  2. Inspect the latest snapshot and the events after its sequence.
  3. Check reliability records before repeating any external tool call. An unknown effect is not safe to retry merely because the model did not see a result.
  4. Fetch the resume plan and use only an enabled operator choice.
  5. Prefer a task retry, requeue, or governed failed-effect retry over recreating the automation.
  6. Require operator review before compensation or abandonment, and preserve the resulting audit event.

The resume-plan choices do not all execute work. At the current runtime revision, failed-effect retry can dispatch governed re-execution and a compensation choice can invoke the compensation engine. Choices such as resume_from_checkpoint or reconcile_external_effect record operator intent and evidence; inspect the response’s execution_mode and automatic_dispatch instead of assuming a POST resumed work.

  • Outbox record: intended external operation and its idempotency/policy context.
  • Tool effect: observed success, failure, or uncertainty plus a bounded receipt pointer.
  • Dead letter: exhausted or unsafe work with explicit recovery choices.
  • Compensation: governed rollback or forward-fix proposal, often requiring approval.

Never compensate by asking a general-purpose agent to improvise an inverse action. Use the recorded target effect and governed compensation path.

  • Public intake requires JSON and rejects bodies larger than 1 MiB.
  • The stored trigger selects tenant, workspace, deployment, automation, and authority. Payload fields never select those boundaries.
  • Signed schemes verify the exact raw bytes. A proxy that rewrites JSON can break verification.
  • Standard Tandem HMAC timestamps have a bounded clock-skew window.
  • Secrets are one-time or provider-owned material. Do not place them in prompts, workflow JSON, logs, screenshots, or artifacts.
  • unsigned_dev_mode is local development only and must never be exposed on an internet-facing callback.
  • Delivery statuses include received, accepted, rejected, duplicate, suppressed, disabled, and failed. Read the reason codes rather than inferring execution from HTTP 202.
  • The run record retains a sanitized preview for inspection; nodes do not currently receive it as a public input. Fetch current provider data through a narrowly authorized connector instead of trusting event content.

The current stateful runtime kernel stores events, snapshots, waits, and reliability records in JSON and JSONL files. This is suitable for the current single-server local/runtime profile, but those files do not provide cross-process compare-and-swap.

Do not run multiple independent Tandem engine processes against the same runtime data root. See the Stateful Runtime Durable Kernel note for the transactional-storage direction.

When an MCP-connected agent is asked to build or repair a stateful workflow:

  1. Authenticate and verify engine health.
  2. Discover MCP availability and exact tool IDs before authoring policies.
  3. Choose Automation V2 and separate validation, work, approval, and external mutation into distinct nodes.
  4. Give each node only the tools and inputs needed for that step.
  5. Use a triage guard with fixed, read-only authoritative checks and return has_work: false when no in-scope work exists.
  6. Preview or review the definition before activating triggers.
  7. Treat 202 as intake acknowledgement and follow the delivery-to-run IDs.
  8. Never decide the workflow’s own human approval gate.
  9. Inspect checkpoint, snapshots, and reliability records before retrying.
  10. Preserve provenance and promote only reviewed knowledge for later runs.
MistakeCorrect approach
Putting a node ID in a webhook payload and expecting a jumpStart at roots; use a guard and DAG dependencies
Prompting a root node to read automation_webhookInspect that snapshot metadata as an operator; use a fixed read-only connector lookup for node-visible routing today
Treating provider_event_kind as a provider payload filterUse separate triggers as needed and validate authoritative project, labels, and state in the guard
Giving the guard or approval node the send toolGive it only to the post-approval node
Treating HTTP 202 as workflow successInspect delivery status and queued_run_id
Reusing an event ID with different JSONUse immutable content for one stable event ID
Recreating the automation after one node failsRepair the affected run or task from its checkpoint
Retrying an uncertain external effect blindlyInspect outbox, effect, and receipt records first
Treating artifacts or replay data as promoted memoryPromote reviewed project knowledge explicitly
Omitting a correlation value from a webhook waitBind a stable provider event ID, idempotency key, or body digest from a literal or declared upstream output