Plan — #323 user-authored workflows (DSL)¶
Status: grill-locked design (Q1–Q9 in the conversation; mirrors the #298 skill model). This is the build plan; the spec lives in
workflows.md§22. Flat integer phases (CLAUDE.md). P1–P3 shipped (PR #332); P4–P5 shipped (the second PR) — full v1 is now in.
Why¶
323 = do for workflows what #298 did for skills. Let a user co-design a¶
workflow with the AI (like a skill), save it, download it to hand off, and
have a dev promote it to a profile default. The twist a skill doesn't have:
a workflow executes. So the user can't author code — running user Python
in the API is unsafe (the orchestration run() is trusted backend Python that
holds the turn engine / sandbox lifecycle / capability credential, manual §1).
Locked design (grill)¶
| # | Decision |
|---|---|
| Q1 | Tiered: user self-serves a constrained/downgraded workflow that runs, and the good ones promote to a trusted default. |
| Q2 | Declarative data + trusted interpreter (a workflow.json "downgraded DSL"). No user code runs in the API. |
| Q3 | Skeleton DSL (sequence / map / agent / gate / capability) + sandbox_node as the one escape hatch for arbitrary deterministic logic (runs in the sandbox). Branch via data-routing (§11). |
| Q4 | Author-equals-hand invariant: a user workflow does exactly what its author could do by hand. Capabilities are DSL primitives run under the captured user's authz; a user sandbox step is compute-only (no run-scoped credential). |
| Q5 | Mirror the skill model: a workspace workflow lives at <workspace>/.workflows/<id>.json (FileStore), item-local, live-read, shadows a package workflow of the same id, downloadable via the generic folder download, dev-promoted. Not a specstar resource. |
| Q6 | One interpreter serves both tiers: a package workflow can be run.py (trusted Python) or workflow.json (interpreted DSL). Promote = copy the json into the profile; no transpile. |
| Q7 | v1 expressiveness ceiling: sequence + map (one level) + agent + sandbox + capability + single-shot gate (approve/reject) + declarative checks + template interpolation. No revise-loop / branch / nested map; custom deterministic logic → a sandbox step. Covers memory/consolidate fully; collections-style polish stays dev-Python. |
| Q8 | Co-design = draft + validate at save (save_workflow runs the validator, rejects with a reason the AI fixes). No auto-test-run in v1; the user presses Run to test. |
| Q9 | Same caps as package workflows (existing per-run timeout / max-steps / concurrency, manual §16–§17). Authz = item access. Captured-user scope. |
The DSL (workflow.json)¶
{
"schema": 1,
"id": "ingest-logs",
"title": "File uploads into collections",
"phases": [
{ "id": "classify", "title": "Classify" },
{ "id": "review", "title": "Review" },
{ "id": "commit", "title": "Commit" }
],
"config": { "collections": ["logs", "specs"] },
"steps": [
{ "type": "map", "over": "uploads/*", "as": "file", "phase": "classify", "do": [
{ "type": "agent",
"prompt": "Read {file}. Pick a collection from {config.collections}; write a digest. Output JSON {collection, digest, source}.",
"out": "plan/{file}.json",
"tools": ["read_file", "ask_knowledge_base"],
"check": { "choice_in": { "path": "plan/{file}.json", "key": "collection", "allowed": "{config.collections}" } },
"retries": 2 } ] },
{ "type": "gate", "phase": "review", "title": "Approve filing these?", "summary_from": "plan/*.json", "allow": ["approve", "reject"] },
{ "type": "map", "over": "plan/*.json", "as": "p", "phase": "commit", "do": [
{ "type": "capability", "call": "ingest_to_collection", "collection": "{p.collection}", "path": "{p.source}" } ] }
]
}
- Step types (
typeis the msgspec tag):agent,sandbox,gate,capability,map. - Interpolation
_resolve(template, ns)— deterministic, async, no eval. Namespace ={config, inputs}+ the active map var.{x}→ the bound value;{x.y}→ index a dict; **{x.y}wherexis a path to a.jsonfile → read - parse + index** (this routes the agent's recorded decision to a capability —
the §8 decision/action split). A template that is exactly
{expr}returns the resolved value (may be a list); otherwise it string-substitutes. mapbindsasto each matched path (sorted glob → deterministic step identity, §9). One level; no nesting (Q7).- Capabilities (v1):
ingest_to_collection,upsert_context_card. Run under the captured user (Q4). Asandboxstep gets no credential (compute-only). - Checks (declarative):
file_nonempty,choice_in,collection_has.
The interpreter is a trusted ProfileRun (async def run(wf, inputs)) that
walks steps and dispatches to the existing agent_step / sandbox_node /
human_gate / wf.ingest_to_collection / wf.map primitives. The §9
filesystem-journal + input-hash skip works unchanged: the DSL is fixed data and
interpolation is deterministic, so each step's identity is stable across re-runs.
Phases¶
- P1 — DSL engine (
workflow/dsl.py). Schema (msgspec tagged union),build_run(def) -> ProfileRun,build_manifest(def) -> WorkflowManifest,validate_def(def, *, tool_ceiling, capabilities) -> list[str], async_resolveinterpolation. Unit-tested against a fakeWorkflowHandle. - P2 — package-tier discovery.
discovery.load_run_callableruns aprofiles/<p>/workflows/<id>/workflow.json(the interpreter) in place of arun.py(Q6 — JSON wins when both exist);authoring._check_workflowvalidates it viavalidate_defso the bundled-clean CI gate + boot fail loud on a bad one. This makes the interpreter runnable in production and is the promote target. Declaration-driven for v1: a package DSL workflow is still listed in_profile.jsonworkflows: [...]like any other (so discovery/the Run picker are untouched), so promote = drop the json and add the one-line_profile.jsonentry. A self-describing drop-in scan (no_profile.jsonedit, the fuller skill-parity) is a follow-up. Living example:apps/playground/profiles/dsl. - P3 — authoring.
save_workflowagent tool (validate → write<ws>/.workflows/<id>.json) mirroringsave_skill;author-workflowshared meta-skill (sample-skills/author-workflow/SKILL.md) opted-in viaapp.jsonagent.skills(topic-hub is the first adopter); workspace-workflow listing (workspace_store.workspace_workflow_metas, for the panel + P4 discovery). The tool-ceiling clamp (Q4) is enforced at save (_profile_tool_ceiling). Co-design - export (generic folder download) + dev-promote — the #298 skill flow, for workflows. (The Workflows-panel route + FE land with P5.)
P4 / P5 (shipped — second PR)¶
- P4 — workspace self-serve run ✅. An injected
load_workspace(item_id, workflow_id) → (run, manifest) | Noneon the orchestrator resolves a<ws>/.workflows/<id>.json(the interpreter + its manifest), shadowing a package workflow of the same id (Q5);_resolve_run/_resolve_manifesttry it first on every reach (start + each resume) and fall back to the packageload_run/load_manifest(defaultNone⇒ existing behaviour unchanged, so no existing test moved). The Run-route_workflow_manifest_or_404gains the same workspace fallback (no 422 for a workspace id), and a new item-scopedGET /a/{slug}/items/{item_id}/workflowslists them.workspace_store.load_workspace_workflowis the one read both share. So a user saves aworkflow.jsonand presses Run — the existing Run endpoint / orchestrator / journal / gate machinery runs it. - P5 — FE Workflows panel ✅.
WorkflowsModal(mirrorsSkillsModal): lists the workspace workflows with a per-row Run button (the self-serve trigger viaworkflowApi.startRun), a Download all (.workflows/folder zip) and Import (.json→.workflows/).useWorkspaceWorkflows+api/workspaceWorkflows+ aworkspaceWorkflowsquery key +workflows.*i18n; opened from a header button inAgentPanelnext to Skills.
Deferred / non-goals (v2)¶
revise-loop / branch / nested map; auto-test-run during co-design (make_deck style); transpile-to-Python on promote; per-user quota.