ADR-0019: Workflows are operator-authored, parameterised DSL artifacts agents invoke

  • Status: Accepted
  • Date: 2026-05-25
  • Deciders: @karasu
  • Supersedes:
  • Superseded by:

Context

The motivating scenario for the entire guest tier (ADR-0017, ADR-0018) is the operator giving a non-operator a constrained way to accomplish a specific job — adding a testimonial to a static site, drafting a blog post, kicking off a deploy. The pattern is the same each time: an operator-authored recipe the agent executes against operator-supplied parameters from the invoker.

The naive way to express this is "let the guest chat with the primary agent, with tool access restricted." That fails on three counts:

  1. The agent has no guard rails for what the guest can ask it to do. A photographer's testimonial request and a deploy-the-site request are very different in risk and shape, but a free-form chat treats them identically.
  2. The required inputs are not declared. Without an input schema, the agent has to negotiate them in conversation every time. That's fine for an operator pairing with their agent; it's terrible UX for a guest who knows exactly what they want.
  3. The prompt that frames the work is buried in operator memory. "How does the agent know to deploy after editing?" becomes folklore. The operator has to retype the framing every time, or hope the project's primary system prompt covers all cases.

What we want is a named, parameterised, prompt-bound recipe that:

  • Lives in the project DSL (portable, version-controlled, reviewable).
  • Has a declared set of required and optional inputs (so the invoker is asked for exactly what's needed, in a structured way).
  • Has a system-prompt fragment that frames the agent's task for the duration of this invocation.
  • Has a tool allowlist that intersects with the project's overall tool config (so a workflow can be more restrictive than the project, but never broader).
  • Is invokable by the primary agent on behalf of the operator or a guest, with the same lifecycle either way.

There is a naming collision to handle. The project already uses two terms that look like candidates:

  • tasks: — operator-initiated shell commands per task-runner.md. Not the same thing. Shell commands, not agent recipes. Name stays.
  • Mastra Workflow — the substrate's control-flow primitive with suspend/resume per ADR-0012. Implementation detail, not operator-facing. Used by the harness; doesn't appear in the DSL.

We use workflow as the operator-facing word. It's the noun a layperson would use ("there's a workflow for adding testimonials"), and it doesn't conflict at the layer the operator sees. The fact that Mastra has its own internal type called Workflow is harness business — operators don't see Mastra types, per ADR-0012 and ADR-0014.

Decision

The project DSL gains a top-level workflows: block — a named-object map (ADR-0015 shape) where each entry declares a parameterised, prompt-bound recipe with a required-inputs schema and a tool allowlist. Workflows are invokable by the primary agent on behalf of operators (via session UI) or guests (via the guest UI, gated by ADR-0018 grants). The harness compiles each invocation into a constrained agent run with the workflow's prompt appended, inputs bound, and tools intersected.

DSL shape

version: 1
project: photographer-site

# ... primary, subagents, tasks, tools ...

workflows:
  testimonial.add:
    description: Add a client testimonial to the site
    system_prompt: ./workflows/testimonial-add.md
    inputs:
      name:
        type: string
        required: true
        max_length: 80
      quote:
        type: string
        required: true
        max_length: 500
      photo:
        type: file
        required: true
        accept: ["image/jpeg", "image/png"]
        max_size_kb: 2048
      link:
        type: string
        required: false
        pattern: "^https?://"
    tools:
      allow: ["file.read", "file.write", "image.optimize", "git.commit", "build.run", "deploy.run"]
    confirm_required: true
    invokable_by: [operator, guest]

  blog.draft:
    description: Draft a blog post from a topic and an outline
    system_prompt: ./workflows/blog-draft.md
    inputs:
      title:
        type: string
        required: true
      outline:
        type: string
        required: true
    tools:
      allow: ["file.read", "file.write", "search.web"]
    invokable_by: [operator]

The workflows field is a named-object map per ADR-0015, keyed by workflow name (dot-delimited, mirroring tools: conventions). Values are WorkflowDefinition objects or null (nullification, per ADR-0015 federated semantics).

WorkflowDefinition schema

Field Type Required Meaning
description string yes One-line human-readable description. Shown to operators in UI and to guests on the invocation form. Max 280 chars.
system_prompt path yes Project-root-relative path to a Markdown file containing the workflow's system prompt fragment. Validated per ADR-0011 path rules.
inputs object yes Named-object map of input parameters. May be empty ({}) for a workflow that takes no inputs.
tools.allow string[] yes Tool names the workflow is permitted to use. Must be a subset of the project's enabled tools (per tools: block, ADR-0015).
tools.deny string[] no Additional explicit denials, useful in conjunction with glob allow patterns.
confirm_required bool no If true, guests are shown a confirmation step before the workflow runs. Default false. Operators may always skip via UI.
invokable_by enum[] no Subset of ["operator", "guest"]. Default ["operator"]. A workflow with guest in this list is eligible for guest grants; the actual grant lives in project_guest_grants.
timeout_seconds int no Max wall-clock for the invocation. Default 600 (10 minutes).
cage_overrides object no Per-invocation cage tightening. Cannot loosen project cages; can only narrow. Reserved for v1.x; not implemented in v1.

Input schema

Each inputs.<name> is a small Zod-shaped schema:

Field Type Required Meaning
type enum yes One of string, integer, number, boolean, file, url.
required bool no Default true.
description string no Shown in the invocation form's field label.
max_length / min_length int no For strings.
min / max number no For integer / number.
pattern string no Regex (re2-compatible) for strings and urls.
accept string[] no MIME types for files.
max_size_kb int no For files.
enum string[] no Restricted set of allowed values.
default (matches type) no Default value when not supplied.

Inputs are validated at invocation time against this schema. Validation failure returns a structured error to the invoker (operator UI or guest UI) before the agent starts.

File inputs

File-typed inputs are uploaded via a separate endpoint (POST /api/v1/projects/:id/workflows/:name/upload) which streams the file to a workflow-scoped staging area, returns a token, and that token is what's submitted with the rest of the inputs. The agent reads the file from staging using the standard file.read tool against the staging path. Files are cleaned up after the invocation completes (or after a 1-hour TTL on abandoned uploads). Detailed in specs/workflows.md.

Invocation lifecycle

  1. Invoker (operator or guest) selects a workflow.
  2. UI renders an input form generated from the inputs schema.
  3. Invoker submits. Daemon validates inputs against the schema.
  4. Daemon creates a workflow run — a special session type with kind: "workflow", workflow_name, frozen inputs, and a reference to the invoking principal.
  5. Harness composes the agent config:
    • instructions = project's primary system prompt + workflow's system prompt fragment (concatenated with a clear delimiter).
    • tools = intersection of project tool config and workflow tools.allow / tools.deny.
    • Inputs are injected as a structured first message: <workflow_inputs>...</workflow_inputs>.
  6. Primary agent runs. Streaming output goes to the invoker's UI in real-time per ADR-0016.
  7. Completion. Run ends with succeeded or failed. Result is persisted to the session record.
  8. Audit log captures every step with the invoker's user_id.

Prompt composition

The agent's system prompt at invocation time is:

[project primary prompt content]

---

### Workflow: testimonial.add

[contents of ./workflows/testimonial-add.md]

### Workflow inputs

name: "Cara McGee"
quote: "They captured our day perfectly."
photo: /workflow-staging/<run_id>/photo.jpg
link: (omitted)

The composition is operator-readable: the full prompt is visible in the run's debug view, per the manifesto principle that no system prompt is hidden. The AuditProcessor logs the composed prompt per specs/agent.md.

Federated config composition

project.local.yaml may override or nullify workflows from project.yaml, per ADR-0015:

# project.local.yaml
workflows:
  testimonial.add:
    tools:
      allow: ["file.read", "file.write", "image.optimize"]   # narrower than project.yaml; no deploy
  blog.draft: null                                            # disable this workflow on this operator's machine

Operator-local overrides are useful for:

  • Disabling workflows during development without touching the portable DSL.
  • Narrowing tool allowlists for an operator who's experimenting.
  • Overriding confirm_required for an operator who wants extra friction on their own runs.

The merge follows ADR-0015's deep-merge with nullification semantics.

Mastra integration

The harness compiles each workflow invocation into one of:

  • Constrained Mastra Agent — instructions composed as above, tools restricted, abortSignal wired to operator pause / timeout. The default path; covers the testimonial-style "agent does the thing" workflows.
  • Mastra Workflow (the substrate primitive) — used for workflows that have explicit suspend/resume points or branching control flow. Reserved for v1.x; v1 ships the Agent path only.

The choice is harness implementation detail per ADR-0011 and ADR-0012. Operators see a workflow; the harness picks the substrate primitive.

Consequences

What this commits us to

  • A new top-level workflows: field in the DSL, with full schema in docs/specs/project-dsl.md and JSON Schema at kaged.dev/schema/v1.json.
  • A new spec docs/specs/workflows.md covering: input schema reference, file upload protocol, invocation lifecycle, prompt composition, run record schema, error taxonomy.
  • API endpoints under /api/v1/projects/:id/workflows/*: list available, invoke, upload file, get run, stream run.
  • UI: a workflows tab in the operator project view (/projects/:id/workflows) and a primary surface in the guest project view (/g/:project_id).
  • Validation timing: schema-level at DSL parse, prompt-file presence at project-load, tool intersection at session-start, input shape at invocation.
  • Audit events: workflow.invoked, workflow.completed, workflow.failed, workflow.upload, workflow.upload_expired.
  • Harness changes per docs/specs/agent.md: composed-prompt path, workflow-scoped tool registry, structured-input first message.

What this forecloses

  • No guest-authored workflows. Workflows are operator-authored, end of story. Guests invoke; they do not define. A guest who wants to do something not covered files an issue (ADR-0020).
  • No dynamic / runtime workflow construction. The DSL is the source. No "if a guest does X, generate a workflow that does Y." If you find yourself wanting this, the workflow you actually want covers a parameterised case — author that.
  • No workflow → workflow chaining in the DSL. A workflow's prompt may instruct the agent to call another workflow tool, but there's no declarative then: another.workflow field. Composition is via prompt, not via DSL.
  • No tool-set broadening. A workflow's tool allowlist must be a subset of the project's. Workflows can be narrower; never broader. The operator can't accidentally grant a workflow more than the project allows.
  • No external workflow registry. Workflows are local to a project's DSL. No "import shared/workflows/testimonial.add" syntax. If two projects need the same workflow, the operator copies the YAML. Sharing is a future plugin concern, not core.

What becomes easier

  • Exposing a constrained surface to a guest: define the workflow, grant the guest, done.
  • Operator automation of recurring tasks: the same workflow runs from the operator UI with one click.
  • Reviewing what guests can do: the DSL workflows: block is the catalog; the permission_set is the assignment. Both are auditable artifacts.
  • Prompt iteration: edit ./workflows/testimonial-add.md, daemon hot-reloads, next invocation picks it up.
  • Tool scoping: a workflow can declare exactly the tools it needs, even if the project has more.

What becomes harder

  • DSL has a new top-level concept. Operator onboarding gains a section.
  • Prompt composition has two sources (project primary + workflow). The composed text is what the audit log captures; the operator has to read both to understand a run.
  • File uploads are a real surface with a real attack surface (size limits, MIME validation, staging cleanup). Documented in specs/workflows.md.
  • A workflow's tool allowlist can drift behind the project's tools: config; we validate at session-start, but operators may be surprised by tool removals breaking workflows. Diagnostics in the UI on project load flag this.

Alternatives considered

Alternative A — Free-form agent + per-grant tool allowlists

Why tempting: No new DSL surface. Guests chat with the primary agent, just with fewer tools.

Why rejected: No structured inputs. No declared framing. Every interaction is a fresh negotiation between the guest and the agent. Works for collaboration; fails for "do this specific task." Workflows are the difference between "I can use Claude" and "I have an app."

Alternative B — Mastra Workflow exposed directly in the DSL

Why tempting: Substrate already has the primitive. Suspend/resume, typed schemas, the works.

Why rejected: Leaks Mastra types into operator-facing config — violates ADR-0011 portability and ADR-0012's "operator never sees Mastra types" line. The DSL must compile to a substrate, not embed it. The kaged workflow concept is portable; Mastra is implementation detail.

Alternative C — Plugins for each workflow

Why tempting: Plugins (ADR-0008) already exist. Each workflow becomes a small plugin with declared inputs and a JSON-RPC interface.

Why rejected: Plugins are processes. Spinning up a process per workflow invocation is heavyweight; workflows are usually a prompt and a tool list. Plugins make sense for capabilities (LLM providers, preset sources, language servers); workflows are recipes using existing capabilities. Different layer.

Alternative D — Defer workflows; use only confirm_required system prompts

Why tempting: Smaller v1. Workflows can come in v1.x once the auth tier is proven.

Why rejected: The auth tier (ADR-0017, ADR-0018) has very little reason to exist without something for guests to do. Workflows are the something. Shipping guest auth without workflows is shipping a login screen to nowhere.

Alternative E — Generate workflows from natural-language operator descriptions

Why tempting: Operator types "I want a workflow that adds a testimonial to my site"; the system generates the YAML.

Why rejected: Maybe a useful tool in the operator UI later, but it's a helper — the artifact is still the YAML, reviewed by the operator, committed to the repo. Generation is layered on top, not in place of, the DSL. Out of scope here.

Open questions

  1. Naming format for workflows. Dot-delimited (testimonial.add) like tools, or slash-delimited (content/testimonial) like routes, or kebab (testimonial-add)? Lean dot-delimited for consistency with the existing tools: namespace.
  2. Default tool allowlist. If a workflow omits tools.allow, does it inherit the project's enabled set? Lean no — require explicit allow. Conservative default forces operators to think.
  3. Input ergonomics for arrays / nested objects. v1 schema is flat (no type: array, no type: object). Most workflows don't need them; deferred to v1.x.
  4. Result schema. Should workflows declare an output shape (e.g., "this workflow produces a deployed URL")? Lean yes for v1.x — useful for chaining and for UI display. Out of v1 to keep the schema small.
  5. Cost budget per workflow. Should each workflow declare a max token / max cost ceiling per invocation? Probably yes once budget tracking is wired (STATUS.md tech debt); v1 ships without and relies on global project budgets.
  6. Cage interaction. v1 ships without cage_overrides. Workflows run with the project's existing cage policy. Per-workflow tightening is plausible v1.x.
  7. Subagent participation. Can a workflow's primary call subagents declared in the project? Lean yes — same supervisor pattern as a normal session. Worth confirming the message-filter implications for guests (subagent output should be visible to the invoker the same way primary output is).

References

Amendments

2026-05-26 — Tool intersection operates against root agent's tool surface (ADR-0022)

Workflow model is unchanged (still operator-authored, parameterised, prompt-bound recipes). Tool intersection logic now operates against the root agent's tool surface instead of the removed project-level tools: block, per ADR-0022. Since tools are now per-agent on AgentSpec and the project-level tools: block no longer exists, workflows compose against ProjectDsl.primary.tools (the root agent's resolved tool set). The intersection semantics are identical — workflows can narrow, never broaden — but the reference point is the root agent, not a project-level construct. Spec amendment in docs/specs/workflows.md.