PUNKthe adaptive runtime

//DOCS Workflows

Workflow builder, templates, scheduling, credentials, and endpoints.

Workflows

Punk workflows are multi-step agent jobs (classify, fetch, branch, call tools, notify) defined as a graph and optimized by the same runtime loop that optimizes single chat requests.

Design Rules

  • Workflows are interpreted, never code-generated. The graph is data; there is no prompt-to-code compiler and no generated JavaScript to audit.
  • LLM nodes are real gateway runs. Every llm node goes through Punk's router, so it inherits governance, caches, artifact routing, fingerprinting, and the learning loop.
  • A workflow run owns its workflow.node.* events for review and audit.
  • Scheduling reuses durable jobs. A minute-cadence sweep reads the workflows table fresh each pass, so deleting a workflow always deletes its schedule.

The Workflow IR

A workflow's graph is a WorkflowIR: { version: 1, inputSchema?, entryId, nodes }. Graphs are DAGs: cycles and unreachable nodes are rejected at create/update/import time and again before every run.

inputSchema is an optional locally enforceable JSON Schema for the complete run input. Punk checks it before the first node. An invalid input produces a failed workflow run with input-contract evidence, zero executed nodes, and no model or tool cost. Graphs that omit it keep the existing open input behavior.

Nodes read and write a shared variable environment seeded with the run input (also available as input). Expressions (IRExpr) are the only way config reaches that environment:

KindShapeMeaning
literal{ "kind": "literal", "value": ... }A constant.
ref{ "kind": "ref", "path": "triage.json.priority" }Dotted path into the vars environment.
template{ "kind": "template", "template": "{{page.context}}" }String interpolation over vars.

Node Kinds

KindConfigWhat it does
start(none)Entry marker; follows next.
llmpromptTemplate (required), system?, model?, saveAs?Renders the templates over vars and executes a real gateway chat run. Saves {content, json, runId, route, costUsd, savedUsd} to vars[saveAs ?? "llm"]; json is a best-effort parse of the content. A supplied child finishReason must be a complete text stop; truncated, non-text, empty, or otherwise invisible completions fail the node after cost is recorded.
tool_callserverId (required), tool (required), args? (map of name → IRExpr), saveAs?Invokes a tool on a registered external MCP server. Governance-checked first; see below.
web_fetchurlExpr (IRExpr, required), saveAs?Web fetch. Saves {context, tokensSavedEstimate} to vars[saveAs ?? "page"], compact page context instead of raw HTML.
web_acturl (required), intents (required)Opens a temporary governed web session, executes click/type/select/submit intents, saves {sessionId, source, url, results, context}, then closes the session.
choicebranches (required), otherwise?Branches evaluated in order; first match wins, else otherwise, else next, else the run ends. Conditions are a full IRExpr or the shorthands contains: [path, needle], displayText: [path, maxChars], eq: [path, value], truthy: path. displayText accepts only bounded, trimmed, single-line text without control or bidirectional formatting characters.
set_varname (required), value (IRExpr, required)Assigns into the vars environment.
transformexpr (IRExpr, required), pick? (string[]), saveAs?Evaluates the expression, optionally picking keys from an object result.
mapitems (IRExpr → array, required), itemVar (required), indexVar?, collectInto (required), body ({entryId, nodes}, required), maxIterations?, continueOnError?Runs the body sub-graph once per item of items in a child scope (itemVar/indexVar bound, never leaked to the parent), collecting each iteration's output into vars[collectInto]. See Map iteration.
notifychannel (webhook | slack, default webhook), target (IRExpr, string, or cred:<id>, required), messageTemplate?, payloadTemplate?Posts to an external destination. slack sends the incoming-webhook {text} shape; webhook posts the rendered JSON payload (else {message}). SSRF-guarded: private destinations always blocked, cred:<id> resolves from the vault, 5s timeout. See Notify channels.
outputvalue (IRExpr, required)Terminates the run with the value.

Execution Limits

  • 100 nodes per run (also the cycle backstop), 120s wall clock.
  • A node failure fails the run. There are no per-node retries; scheduled runs retry whole-run through the job queue (up to 3 attempts), manual/API runs never retry.

Manual workflow and agent runs return { run, auditAvailability }. A completed run remains authoritative when its terminal workflow.run audit write rejects or exceeds the bounded wait: the request still returns the stored run with auditAvailability.status set to unavailable. Clients must not retry the workflow from that availability signal because its nodes, including external side effects, have already executed.

Interpreted, Never Code-Generated

Workflows follow the same principle as artifacts: a declarative structure executed by the gateway, never generated or eval'd code. Effects such as LLM calls, MCP tools, web fetches, webhooks, and tracing stay visible node by node.

LLM Nodes Are Gateway Runs

This is the optimization story. Each llm node is a loopback request into Punk's own router with app id workflow:<id> and agent workflow-engine, temperature 0. That means:

  • The node is governed, fingerprinted, and traced like any other run.
  • Repeated eligible node work can hit caches, form patterns, and route through proven optimized paths without changing the graph. The workflow reports measured savings when that reuse actually occurs.
  • Per-node cost and savings are read from the persisted child gateway run and roll up onto the workflow run's costUsd/savedUsd.

GET /api/v1/workflows/:id/savings aggregates this across run history. Total cost includes valid incurred cost from every run. Total saved credits completed runs only. spendAvoidedShare is the share of would-have-been spend the optimizer avoided. The response retains optimizedShare as a compatibility alias with the same value.

Map Iteration

A map node runs a self-contained body sub-graph once per item of an array expression. Each iteration binds itemVar (and optional indexVar) in a child scope that is a shallow copy of the parent vars, so iteration variables never leak back out; the per-iteration output is collected into vars[collectInto]. Bodies can contain llm and tool_call nodes, so every iteration is a real metered, governed, cacheable gateway run, and child costs/savings roll up onto the workflow run.

Fan-out is bounded on three axes so a map can't run away:

  • maxIterations caps the loop count (default 100, hard ceiling 1000).
  • Body nodes count against the same shared node budget (100) and 120s wall clock as the rest of the run.
  • Map bodies nest to a maximum depth of 3 (a map inside a map inside a map).

continueOnError (default off) collects { error } for a failed item and continues; otherwise the first body failure fails the map node and the whole run. The batch-triage template demonstrates mapping an LLM classification over a list of tickets.

Notify Channels

The notify node posts to an external destination over one of two channels:

  • slack sends the Slack incoming-webhook { text } shape, rendered from messageTemplate (or the default message).
  • webhook posts the rendered payloadTemplate as JSON (falling back to {message} when the template doesn't parse).

The target is an IRExpr, a literal URL string, or a cred:<id> reference resolved from the encrypted vault at run time, so a Slack webhook URL or a signed endpoint never lives in the graph. Either channel is SSRF-guarded under the stricter webhook posture: private/loopback destinations are always blocked, with a 5s timeout.

Builder UI

#/workflows is the built-in visual workflow creator. Start here when you want a multi-step agent job without writing code.

The fastest path:

  1. Open the template gallery.
  2. Pick vendor-risk-review, customer-escalation, support-triage, web-research, or pricing-monitor.
  3. Click USE TEMPLATE.
  4. Click RUN.
  5. Paste JSON input and inspect the returned output, cost, savings, and run timeline.

After that, edit the graph. #/workflows lists workflows (enabled toggle, node count, cron badge, last run, per-workflow savings) plus the template gallery. #/workflows/:id is a three-pane editor:

  • Palette: click or drag node-kind chips onto the canvas.
  • Canvas: drag nodes, drag the background to pan, click a node's output port then a target to connect (choice sources add a branch). DEL removes the selected node/edge, ESC cancels a connect, AUTO-LAYOUT does left-to-right topological layering.
  • Inspector: per-kind config forms; IRExpr fields are kind+value selects. Validation errors from a rejected save render inline.

Toolbar: SAVE (dirty indicator), RUN (JSON input panel, synchronous result with a link to the run timeline), EXPORT (downloads the export envelope), DELETE.

Use Agents instead of the workflow editor when the job is just one scheduled prompt. Use Workflows when you need multiple nodes, branching, web fetches, tool calls, notifications, or export/import.

Run Timelines And Node Traces

Every node emits workflow.node.started, then workflow.node.completed ({durationMs, summary}) or workflow.node.failed ({error}) onto the workflow run. The dashboard uses these events to render node timelines.

GET /api/v1/workflow-runs/:id returns the run plus its events. The dashboard renders this as a node timeline at #/workflow-runs/:id; llm node payloads link through to the child gateway run, where the route explanation says whether the node was served live, from cache, or by an artifact.

When a workflow or agent run fails, the run detail page also shows a failed-node summary with the error and any child gateway-run link that can be recovered from the trace payload. The Recovery panel starts a new workflow or agent run with the same stored input; it does not mutate or replay the original trace, so the new attempt gets its own run id, cost accounting, and node ledger.

Scheduling

Set scheduleCron to a 5-field cron expression (UTC). Supported syntax: *, numbers, lists (1,15,30), ranges (9-17), steps (*/5, 10-50/10). Deliberate limits: no month/day names, no L/W/#/? extensions. When both day-of-month and day-of-week are restricted, the date matches if either matches (standard cron).

How it runs: a minute-cadence sweep job reads the scheduled workflows fresh each pass and enqueues a durable run job per match, deduped per workflow per UTC minute. There are no persistent repeatable jobs to clean up. Deleting (or disabling) a workflow is also unscheduling it, with no orphan schedule left behind.

Credentials

Stored credentials hold the secrets that workflow tool calls and MCP servers need:

  • Encrypted at rest with AES-256-GCM inside the store boundary. Key: PUNK_ENCRYPTION_KEY (32 bytes, base64). Authenticated deployments refuse new credential writes until it is set; open dev mode may use the deterministic dev key and warns loudly.
  • The API never returns a secret in any form. POST /api/v1/credentials accepts the secret once and responds with a masked record; lists are masked too.
  • Reference a credential as cred:<id> in MCP server env/header values. It resolves at connect time, tenant-checked, inside the store.

Tool Calls And Governance

A tool_call node executes against a registered external MCP server. Managed SaaS connectors are first-party MCP servers installed from the connector catalog, so Slack, Google Workspace, GitHub, Salesforce, Postgres, AWS Operations, Workday, DocuSign, Segment, Calendly, Microsoft Dynamics / Dataverse, and the rest of the managed catalog use this same node shape. See Connectors for the current 47-connector, 464-tool catalog and each tool's auth, input, side-effect, replay, shadow, cache, redaction, and idempotency contract.

Before any connection is made, the gateway classifies the tool's side-effect level and runs the governance check with the workflow's agent identity. deny fails the node closed. A live Buzz-origin managed-connector workflow can durably enter waiting_approval; Punk stores the exact interpreter cursor and immutable action authority, publishes the approval request, and resumes only after a signed, request-bound decision. Schedules and other origins without a durable approval boundary still fail closed. tool.called/tool.completed events land on the workflow run with the classified side-effect level.

The checkpoint contains bounded workflow variables and cursor state, never connector credentials or private keys. Resume revalidates graph identity, the exact deciding principal, current Buzz mapping and scope, connector grants, policy, manifest, live schema, action, and canonical arguments. A crash after remote dispatch is reported as reconciliation_required unless the connector can prove a safe read-after-write or idempotent retry; Punk does not claim exactly-once remote execution.

web_fetch nodes use the same SSRF posture as POST /api/v1/web/fetch; notify nodes use the stricter webhook posture (private destinations always blocked).

Export And Import

POST /api/v1/workflows/export returns a { "punkWorkflows": 1, "workflows": [...] } envelope (all workflows up to 1,000, or pass ids to select). A complete export above that limit fails closed instead of dropping older workflows. POST /api/v1/workflows/import validates every entry before creating anything and rolls back any rows it created if storage fails mid-import. Imports are atomic, and a single invalid graph rejects the whole batch with per-entry errors.

Endpoints

Mutations require admin except the read-only export endpoint. See API for auth and response conventions.

MethodPathPurpose
GET/api/v1/workflowsList workflows.
POST/api/v1/workflowsCreate (graph validated; 400 with per-node errors).
GET/api/v1/workflows/:idRead one.
PATCH/api/v1/workflows/:idUpdate; every accepted PATCH bumps version.
DELETE/api/v1/workflows/:idDelete (also unschedules).
POST/api/v1/workflows/:id/runExecute synchronously; body { input?, trigger? }; 422 if the stored graph is invalid.
GET/api/v1/workflows/:id/runsRun history (?limit=).
GET/api/v1/workflows/:id/savingsCost/savings rollup with spendAvoidedShare and the compatibility alias optimizedShare.
GET/api/v1/workflow-runs/:idRun plus its node-level trace events.
GET/api/v1/workflow-templatesBuilt-in templates.
POST/api/v1/workflow-templates/:id/instantiateCreate a workflow from a template ({ name? }).
POST/api/v1/workflows/exportExport envelope ({ ids? }).
POST/api/v1/workflows/importAtomic import of an export envelope.
GET/api/v1/credentialsList stored credentials (masked).
POST/api/v1/credentialsStore a credential ({ name, provider, secret }); secret never echoed.
DELETE/api/v1/credentials/:idDelete a credential.

Templates

Built-in templates ship complete, validated graphs. The demo-app templates also auto-seed a tenant-scoped Punk Demo Apps MCP server when instantiated through the API or dashboard, so they make real MCP calls without asking evaluators for their own SaaS credentials.

TemplateWhat it doesInput
vendor-risk-reviewPulls related demo Gmail, HubSpot, and Stripe records through MCP, stops with an explicit receipt when Stripe has no matching invoice, reviews confirmed invoices with an LLM node, posts the contracted recommendation, risk, rationale, next action, Stripe amount, policy threshold, due date, status, and last-payment status to demo Slack, and emits the Slack receipt. Missing Stripe invoices never reach the model or Slack, including exact older saved graphs. Missing or malformed amount, policy-threshold, due-date, status, and last-payment values appear as Not available. Missing, empty, extra, or oversized identity fields fail before any MCP lookup or Slack post. Blank, non-JSON, incomplete, extra-field, unsupported-enum, or invisible-only model answers fail after the paid model call.{ vendorDomain, vendorName, invoiceId }
customer-escalationPulls demo Gmail, HubSpot, Zendesk, and GitHub records through MCP, plans an escalation, creates a demo Zendesk ticket with the contracted customer subject, customer, HubSpot account name, HubSpot account owner, supported account tier, first safe HubSpot risk note, requester, issue, HubSpot renewal date, first safe related GitHub issue title, repo, state, label, and canonical github.com issue URL, priority, customer impact, suspected owner, and next action, posts the same handoff to demo Slack, and emits the Slack receipt. A missing or malformed HubSpot account name, owner, tier, risk note, renewal date, related issue title, repo, state, label, or URL is shown as Not available. Only enterprise, vendor, and prospect tiers are shown. Only open and closed issue states are shown. URLs must use HTTPS, github.com, and the exact owner/repository/issues/number path. This also applies to exact older saved graphs and paused approvals. Missing, empty, extra, or oversized identity fields fail before any MCP lookup, ticket, or Slack post. Blank, non-JSON, incomplete, invisible-only, or unsupported-priority plans fail before ticket creation or Slack.{ customerDomain, requesterEmail, issue }
support-triageClassify a ticket with an LLM node and emit a bounded classification with an internal routing note derived from the validated category and a handling note derived from the validated priority. Current and exact saved graphs reject non-JSON answers, unsupported labels, and invisible-only optional summaries after model accounting.{ ticket: { subject, description } }
web-researchFetch a page as compact context, summarize it with an LLM node, and emit plain summary text. Missing or non-http(s) URLs fail before the fetch. Empty page context fails before the paid summary. Invalid, empty, or invisible-only summaries fail after model accounting, including exact older saved graphs.{ url }
pricing-monitorFetch a pricing page as compact context, extract plans and prices, and emit a structured report. Pair with a cron schedule to watch competitors. Missing or non-http(s) URLs fail before the fetch. Empty page context fails before the paid extraction. Invalid, empty, or invisible-only extractions fail after the paid model call, including exact older saved graphs.{ url }
batch-triageMap a bounded LLM classification over a list of tickets and emit the batch, including an internal routing note derived from each validated category and a handling note derived from each validated priority. Current and exact saved graphs stop on the first invalid answer, retain its paid cost, and avoid later model calls.{ tickets: [{ subject, description }] }

Instantiate from the dashboard gallery or POST /api/v1/workflow-templates/:id/instantiate.

Demo Apps MCP Server

vendor-risk-review and customer-escalation bind to a seeded MCP server named Punk Demo Apps. By default it is a bundled stdio server at apps/demo-mcp/src/index.ts with seeded Gmail, HubSpot, Stripe, Zendesk, GitHub, and Slack-shaped workspaces. Tool calls are real MCP calls through the same registry and governance path as customer MCP servers; only the app data is Punk-owned demo content.

For hosted sales demos, set PUNK_DEMO_MCP_URL to point at a Punk-owned HTTP MCP service instead of the bundled stdio server. If that service requires a bearer token, set PUNK_DEMO_MCP_BEARER; Punk stores it in the credential vault and references it as cred:<id> in the MCP server header.