Based on AgentRun release · Synced .
@parcha/agentrun-dsl
Typed workflows that your agent writes. It drafts a workflow, checks it, dry-runs it for free, then runs it; @parcha/agentrun-dsl/graph turns the workflow into its graph. The core provides the v2 workflow types, validator, interpreter, events, schema, and an offline CLI. Supply agent, judgment, and effect functions only when a workflow needs them.
Beta, on npm under the latest dist-tag (npm view @parcha/agentrun-dsl dist-tags). Requires Node 22.19+; TypeScript consumers require TypeScript 5.4+.
npm install @parcha/agentrun-dsl@latest
npx @parcha/agentrun-dsl@latest demo
For the JavaScript API, save this as demo.mjs and run node demo.mjs:
import { runTriageDemo } from '@parcha/agentrun-dsl/demo';
const { result } = await runTriageDemo('billing');
console.log(result.output); // { queue: 'billing', priority: 'normal' }
The demo runs real control flow with scripted judgments. ambiguous escalates. No model SDK or API key is required by the core. Optional @parcha/agentrun-jev and @parcha/agentrun-pi adapters add live judgments and agent steps.
From a draft to a run
A workflow is a JSON document of typed nodes: each node that asks a model names the JSON Schema its answer must match, and the workflow names the schemas of its input and output. An agent writes the document. This package checks it, tries it without a model, and runs it.
- Draft. In Pi,
/agentrun <task>has the agent draft the workflow with the packaged author skill (see the Pi extension). In your own host,authorWorkflowgives your agent the same author contract and keeps the candidate and its feedback without running it. - Check.
validateWorkflow(workflow)checks the structure, the schemas, the state paths between nodes and the predicates, with no model call. A refused workflow lists itsissues: each names the node or schema at fault with a JSON Pointerpath, and the rule it breaks with acode, so the agent repairs that node and checks again.inspectWorkflowshows the tree and the tools and adapters it needs, without running any code. - Dry-run, for free.
dryRunWorkflow(workflow)runs the whole workflow on synthetic values made from its schemas, with no model, Jev or tool call. Itsstatusispassedwhen every check ran,failedwith theproblems, ornot_runwhen a check could not run, with the reasons inskipped. Anot_rundry run proves nothing. - Run.
runWorkflow(workflow, input, deps)runs it with your agent, judgment and effect functions, and returns an output that matches the output schema, or an escalation.depsis the object of adapters your application supplies (runNode,runJudge,runEffect); Integrate with your application says what each one owes the engine. - Watch every node.
workflowGraph(workflow)from@parcha/agentrun-dsl/graphlists every node and edge from the JSON alone, andnodeStatusmarks each nodedone,working,failedornot-runfrom what a durable run kept, andescalatedwhere the run's terminal result says it stopped. Pi's/agentrunview draws the same graph, with each node's state as the run goes.
The same checks from a shell, in the project where you installed the package, on one of the author skill's examples that ships with it (an agent extracts the change in each release note, then summarizes the migration):
cp node_modules/@parcha/agentrun-dsl/skills/author/examples/review-release-notes.json workflow.json
npx @parcha/agentrun-dsl@latest inspect workflow.json
npx @parcha/agentrun-dsl@latest validate workflow.json --trusted
npx @parcha/agentrun-dsl@latest dry-run workflow.json --trusted
Its dry run is passed: every node ran on synthetic values, with no model call. The demo's workflow (npx @parcha/agentrun-dsl@latest example demo.json) comes back not_run instead: its confidence check escalates on a synthetic answer, so the queue choice after it is not exercised, and skipped says so. dry-run --strict exits 3 on not_run, for a script that must stop there; a failure exits 1.
Run it with no key
runWorkflow calls back into your application for every model step. To see a run end to end with no key and no network, give it a runNode that returns a fixed record. Save this as run.mjs next to node_modules and run node run.mjs:
import { readFileSync } from 'node:fs';
import { runWorkflow } from '@parcha/agentrun-dsl';
const dir = 'node_modules/@parcha/agentrun-dsl/skills/author/examples/';
const workflow = JSON.parse(readFileSync(dir + 'review-release-notes.json', 'utf8'));
const input = JSON.parse(readFileSync(dir + 'review-release-notes.input.json', 'utf8'));
// deps holds the adapters your application supplies. This runNode answers each
// model step with a fixed record instead of calling a model.
const deps = {
async runNode({ label, user }) {
const state = JSON.parse(user); // the node's projected state, as JSON text
if (label === 'extract-change') {
return { breakingChange: state.text, migrationAction: 'Follow the release note.' };
}
return { summary: `${state.changes.length} breaking changes.`, changes: state.changes };
},
};
const result = await runWorkflow(workflow, input, deps);
console.log(result.status, result.output);
It prints complete and a report with the two changes. Each answer is checked against the node's schema: a runNode that returns {} makes the run throw submission does not satisfy schema "Change". A node whose adapter you did not supply fails with a message that names the node and the adapter (extract node "extract-change" requires runNode). The three adapters take different parameters, listed in WorkflowDeps: runNode (agent, decide, extract and report nodes) receives kind, label, system, user, schema, tools and signal, which a real host forwards to its agent; runJudge (judge, pick, sift, route and ask) receives label, kind, state and questions, and returns an object with the answers; runEffect (call nodes) receives node, input, attempt, idempotencyKey and signal, and returns the tool's result.
The run command of the CLI supplies no adapters, so it runs only a workflow that needs none, such as one made of code nodes. Save this as total.json:
{
"v": 2,
"name": "total",
"schemas": {
"Input": { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "number" } } }, "required": ["items"] },
"Output": { "type": "object", "properties": { "total": { "type": "number" } }, "required": ["total"] }
},
"input": { "schemaId": "Input" },
"output": { "schemaId": "Output" },
"root": { "node": "code", "label": "sum", "code": "(s) => ({ total: s.items.reduce((a, b) => a + b, 0) })" }
}
Save its input as total.input.json:
{ "items": [1, 2, 3] }
Then run it:
npx @parcha/agentrun-dsl@latest run total.json total.input.json --trusted
It prints "status": "complete" and an output with "total": 6. The shipped examples each need an adapter, so run stops on them naming it: review-release-notes.json needs runNode, evidence-gate.json needs runJudge, and read-file.json and read-source-decision.json need runEffect (the latter also runJudge). Run those with runWorkflow and your own adapters.
The agentrun-dsl CLI provides demo, example, inspect, validate, dry-run, and deterministic run. inspect workflow.json [--json] displays structure without executing code. Validation/run require --trusted because workflow JavaScript can execute, including during validation. This package is not an isolation boundary.
runWorkflow(workflow, input, deps) returns a complete schema-validated output or an escalation; its TypeScript output type is unknown. defineWorkflow binds Standard JSON Schema contracts, including supported Zod schemas, to a frozen v2 document; runTypedWorkflow infers those input/output types and delegates to the same interpreter. Intermediate state paths remain runtime-validated. Execution and verification failures throw. Hosts own tools, file delivery, idempotency and activation, and choose where a run's journal lives; the package ships the recovery driver (see Durable runs below). A durable run's store keeps each effect's arguments as given, so redact secrets before they reach an effect.
Raw JSON schemas retain unknown boundary types. Zod transforms, refinements, defaults and coercion are rejected rather than silently dropped. The core does not depend on Zod at runtime. Other Standard JSON Schema providers must faithfully represent their contracts. Typed execution accepts the original defineWorkflow result; copied or deserialized documents use runWorkflow.
A judge on any model
runJudge is a plain function: it receives the node's questions and returns answers, so any model can stand behind it. This one answers every question with a fixed probability, so it runs with no key; replace its body with a call to your model that returns a probability for each question. Save this as judge.mjs next to node_modules and run node judge.mjs:
import { readFileSync } from 'node:fs';
import { runWorkflow } from '@parcha/agentrun-dsl';
const dir = 'node_modules/@parcha/agentrun-dsl/skills/author/examples/';
const workflow = JSON.parse(readFileSync(dir + 'evidence-gate.json', 'utf8'));
const input = JSON.parse(readFileSync(dir + 'evidence-gate.input.json', 'utf8'));
// A yes-probability for every question, as a noul answer.
const runJudge = async ({ questions }) => ({
answers: Object.fromEntries(Object.keys(questions).map(id => [id, { type: 'noul', noul: 0.95 }])),
});
const result = await runWorkflow(workflow, input, { runJudge });
console.log(result.status, result.output?.accepted);
It prints complete true: the workflow's own code gate accepts a yes-probability of 0.90 or more. Set noul to 0.5 and it prints escalated undefined instead. The Jev adapter, @parcha/agentrun-jev, is this same function backed by TypeSafe's judgment service.
Durable runs
@parcha/agentrun-dsl/recovery journals a run so a process that dies can continue it. openRecovery(store, workflow, { key }) opens the run's journal (fileStore(directory) keeps it in a directory, memoryStore() in memory) and withRecovery(driver, deps) wraps your adapters. A run opened again with the same key answers each committed step from the journal and runs only the rest; a tool call that was in flight when the process died has an unknown outcome and is refused, not repeated, unless your reconcile option settles it. The same journal opened for a completed run runs nothing. Save this as durable.mjs and run it three times: CRASH=1 node durable.mjs (the process exits before the last step commits), then node durable.mjs (it runs one model step, the one that did not commit), then node durable.mjs again (zero steps):
import { readFileSync } from 'node:fs';
import { runWorkflow } from '@parcha/agentrun-dsl';
import { openRecovery, withRecovery, fileStore } from '@parcha/agentrun-dsl/recovery';
const dir = 'node_modules/@parcha/agentrun-dsl/skills/author/examples/';
const workflow = JSON.parse(readFileSync(dir + 'review-release-notes.json', 'utf8'));
const input = JSON.parse(readFileSync(dir + 'review-release-notes.input.json', 'utf8'));
let calls = 0;
const runNode = async ({ label, user }) => {
calls += 1;
if (process.env.CRASH && label === 'summarize-migration') process.exit(1); // dies before the last step commits
const state = JSON.parse(user);
return label === 'extract-change'
? { breakingChange: state.text, migrationAction: 'Follow the release note.' }
: { summary: `${state.changes.length} breaking changes.`, changes: state.changes };
};
const driver = await openRecovery(fileStore('./run-journal'), workflow, { key: 'release-review' });
try {
const result = await runWorkflow(workflow, input, withRecovery(driver, { runNode }));
console.log(result.status, `model steps run this time: ${calls}`);
} finally { await driver.close(); }
The first run exits with code 1 and leaves run-journal/; the second prints complete model steps run this time: 1; the third prints complete model steps run this time: 0. Delete run-journal/ to start over. The key names the run, so use one per run; the journal is bound to the workflow and input it was opened with, and a change to either is refused. Hosts keep the store, the key and the decision to resume: see Integrate with your application.
Public API
The package root exports a deliberate API. The beta retains agent, structured-output, report, artifact, effect, child-workflow and focused-evaluation paths. Hosts supply their adapters and policy; a workflow cannot grant itself tool permissions.
| Group | Exports and purpose |
|---|---|
| Typed authoring | defineWorkflow, runTypedWorkflow, WorkflowSchemaConversionError; StandardJSONSchema, AuthoringSchema, TypedWorkflow, WorkflowInput, WorkflowOutput, TypedWorkflowRunResult. |
| Structural inspection | inspectWorkflow, formatWorkflowTree; WorkflowInspection, WorkflowInspectionNode. Reads structure and capability requirements; does not perform semantic validation or run code probes. |
| Run and inspect | runWorkflow, validateWorkflow, workflowSha256, dryRunWorkflow; core contracts Workflow, WorkflowNode, WorkflowDeps, WorkflowRunResult, ValidationIssue and ValidationCode (each validation error's JSON Pointer and rule), plus the named node, predicate, effect, effort and result types. |
| Recognize outcomes | WorkflowInvalidError, WorkflowInputInvalidError, WorkflowOutputInvalidError, WorkflowVerificationError, EffectFailure, EffectDeadlineExceededError, EffectOutcomeUnknownError, EscalationSignal. A concurrent failure may contain typed outcomes in AggregateError.errors; inspect them before deciding whether an effect is safe to retry. |
| Build judgment adapters | compileQuestions, validateAnswers, answersToValue, answersSidecar, answerConfidence, deriveConfidence, completeConfidence, refusedQuestions, SYSTEM_ONE_LIMITS, SystemOneError; SystemOneQuestion, SystemOneAnswer, SystemOneResult, SystemOneRetryClass, CompiledQuestions, AnswersSidecar, ConfidenceSource. |
| Plug in a decision model | judgeFromProvider (a DecisionProvider as runJudge), checkCapabilities, DecisionProviderError; DecisionProvider, DecisionCapabilities, DecisionRequest, DecisionContext, DecisionOutcome, DecisionEvidence, ProviderAnswer, CostBasis. @parcha/agentrun-dsl/testing has registerDecisionProviderConformance, the suite a provider's own tests register: exact answers, a null cost never read as 0, typed errors, abort and deadline, no key in what it returns or throws, its declared limits refused before sending, a refusal kept as one. Jev's provider is jevProvider in @parcha/agentrun-jev; Parallel's is in @parcha/agentrun-decisions. |
| Integrate a host | runWorkflowSlice, assertWorkflowCapabilities, desugarWorkflow, resolveSchemaForWorkflow, stageSchemaForNode, submissionSchemaForNode, mergeStageDelta, REPORT_SCHEMA, SHELL_RESULT_SCHEMA, getPath, predicateMatches, deniedGlobalReferences. |
| Author workflows | authorContract, renderAuthorContract (text and sha256), renderAuthorHostAddendum, authorWorkflow, candidatePolicyErrors, applyHostOutputTypes, AUTHOR_SKILL_NAME, authorSkillDirectory, loadAuthorReference, loadAuthorSkillBundle; AuthorHostAddendum, AuthorWorkflowOptions, AuthoredWorkflow, CandidatePolicyOptions. The one author contract and its packaged skill (skills/author); a host adds only an addendum. See the author contract. |
| Language vocabulary | WORKFLOW_NODE_KINDS, GENERATIVE_NODE_KINDS, JUDGMENT_NODE_KINDS, NODE_FIELDS, IGNORED_NODE_FIELDS, WORKFLOW_PREDICATES, MECHANICAL_PREDICATES, PREDICATE_FIELDS, FIELD_TYPES, EFFORT_LEVELS, THINKING_LEVELS, MODEL_TIERS, CALL_TRANSPORTS, CALL_RETRY_CLASSES, EFFECT_FAILURE_CODES, PROSE_ARTIFACT_TYPES; WorkflowNodeKind, WorkflowPredicateName, FieldType. The validator and the author contract read these same constants. |
| Test without a model | synthesizeInstance, synthesizeAnswers, DryRunOptions, DryRunResult. Synthetic answers exercise execution mechanics; they do not establish judgment quality. A dry run stops after maxSteps synthetic steps (10,000 by default) or at its signal, with skipped saying so. |
A code node runs in the host process through the package's own executor, which shadows common globals and is not a sandbox. A host that runs code from an untrusted author supplies deps.runCode: the interpreter then hands it each code node with the state and the transform's context, merges what it returns under the same patch rules and state checks, and never executes a code body itself, not even as a validation probe. dryRunWorkflow takes the same runCode option and honors it the same way. validateWorkflow is synchronous and cannot call a host executor: a host that has one validates with executeCode: false, as runWorkflow and dryRunWorkflow do for it. deniedGlobalReferences names the globals a code body reaches for (a lint, never a boundary), so such a host can refuse the node before it runs. A failed effect carries a closed code from EFFECT_FAILURE_CODES on its EffectFailure and on every effect.failed event (null when the failure is not an EffectFailure with a listed code; any other typed error's own code rides as error_code), and retry.on names codes as well as retry classes.
runWorkflowSlice runs selected root steps against supplied state and optional seed data. It returns the slice state without checking the full workflow's terminal output contract. Use runWorkflow for a complete run. getPath reads dot-separated record keys and canonical in-bounds array indexes, returning undefined when a segment cannot be resolved. predicateMatches evaluates mechanical predicates only; use field_equals with an explicit path for a stored label.
The schema helpers resolve the owning workflow's schema catalog; stageSchemaForNode and submissionSchemaForNode name the same submission contract. mergeStageDelta stores declared submission fields under the node's output key; callers must validate the submission first. Internal traversal, compilation, CSV, reference-file and input-normalization helpers are not package-root APIs.
@parcha/agentrun-dsl/graph imports nothing, so a static viewer bundles it and a reader with no Node runs it. workflowGraph(workflow) returns { nodes, edges } for a workflow document, computed from its JSON alone: a node is { path, kind, label, via? } with path a JSON Pointer into the document as written (/root/steps/1, /root/branches/<name>/body, /root/body for a map's or a loop's body, /root/workflow/root for a child; the pointers inspectWorkflow and a validation issue use), and an edge is { from, to, kind } with kind one of next (a chain's steps in order, a loop into its body), branch (a parallel node's branches), route (a route's branches), item (a map into its body) and child (a workflow node into its child's root). nodeStatus(graph, source) says, for each node, done, working, failed or not-run, read from what the recovery driver kept: its frame (the pin of the frozen snapshot), the map items' records (agentrun.items, keyed layout), or both as { pin, items }. A run's map item or loop iteration has no node of its own: its instances count toward the body's node, and a node inside a map's or loop's body is done only once the map or loop is. Once the run has ended, pass its terminal result as { pin, items, result }: an escalation (result.status escalated, with result.escalation.executionPath as runWorkflow returns it) marks the escalate node that fired and every container on its path escalated, since the frame holds no mark for that node, and a map or loop on that path has ended, so what finished inside it is done. Without a result the status is what it was. To read one item's record on its own, pass { item: "<map path>/items/<index>" } as a third argument: that item is then the instance, and its body is done when the item is. graphNodeOf(graph, executionPath) names the node an execution path (an event's or a record's) ran at: { path, at }, where path is the node's pointer and at lists the map items ({ kind: "item", index }) and loop iterations ({ kind: "iteration", index }) it ran in, outermost first. An execution path is the node's pointer with items/<i> before a map's body and iterations/<i> before a loop's; the path is read by walking the graph by node kind, so a route branch named items stays a branch, and a path the graph does not have is undefined. graphDiff(prev, next) says what changed between two graphs of a workflow, such as two saved revisions of one: { added, removed, changed, moved, edges: { added, removed } }. A node is the same in both when its (kind, label) is unique in each graph, so a step inserted before others is one added node (its path in next) and the steps after it moved ({ prev, next } paths), never a run of changes; a node whose (kind, label) repeats, or is in one graph only, is matched by its path, and the same path with a new kind or label is changed ({ prev, next, was, now }, was and now each { kind, label }). removed holds nodes of prev only, at their paths there; rewritten holds matched nodes whose kind and label stayed but whose own fields changed (new instructions, a new budget: { prev, next, kind, label }); edges.added and edges.removed hold the edges of one graph only, each in its own graph's paths. Each graph node carries a changeCheck for this: 8 hex digits, the FNV-1a hash of the node's own fields as canonical JSON, its children left out as the walk leaves them out. It detects a change; it is not an identity, and not a saved revision's sha256. The pi session's text view draws the same graph.
The separate @parcha/agentrun-dsl/demo entrypoint provides the support-triage example. @parcha/agentrun-dsl/schema provides the generated workflow JSON Schema, a JSON file: import it with import schema from '@parcha/agentrun-dsl/schema' with { type: 'json' }; (a plain import throws ERR_IMPORT_ATTRIBUTE_MISSING), or load it with require('@parcha/agentrun-dsl/schema') in CommonJS.
Children can contain maps, loops, routes, parallel branches and other children, and appear inside those structures. Their input/state and schema catalogs stay isolated; terminal report/artifact delivery remains parent-owned. New composed structures require path-aware recovery stores when recovery is enabled. executionPath is supplied to adapters, events and checkpoint/recovery hooks. Existing host validators and durable-cursor limits remain authoritative.
Bounds
These are the current beta's validation rules. The numeric knobs below carry no upper ceiling: the author picks the number, and a host may set its own default, but the validator never caps how high it goes. What remains are lower bounds and structural requirements (a floor a value must sit at or above, a field that must be present), plus the parser's own safety guards, and the System-One question shape (a bounded multiple choice). Hosts may still impose their own budgets as defaults.
| Setting | Bound |
|---|---|
| Document traversal | At most 100,000 visited values and 128 levels of object nesting; cycles, accessors and proxies rejected |
loop.maxIters |
Integer >= 1 |
loop.expect_iters |
Integer >= 1, optional (gate prices the loop at this; default the floor) |
verify.maxDrives |
Integer >= 1; default 2 |
call.deadline_s |
Required; a finite number greater than 0 seconds |
call.retry.attempts |
Integer >= 1, including the first attempt |
call.retry.backoff_s |
Finite number >= 0 |
call.poll.interval_s |
Finite number, at least 0.1 seconds (busy-loop guard) |
call.poll.deadline_s |
Finite number, at least the call deadline |
| Choice options / route branches | At most 240; pick.allowNone reserves one option |
| Score rubric | 2–10 levels, indexed from 0 |
| Questions per judgment request | Default 256; override with host-owned WorkflowDeps.maxQuestionsPerRequest |
| Judge state bytes per request | No default; host-owned WorkflowDeps.maxStateBytesPerRequest (UTF-8 JSON bytes) |
| Jev context | Selected provider's token limits; an adapter host may configure an additional byte guard |
The question-count guard applies after collection expansion, including sift items multiplied by their questions. It is a host budget guard, not a claim about a provider's service limit. A sift over either limit is split, in item order, into requests that each fit, run up to four at a time. Each request carries the sift's state and only its own items.
System One names TypeSafe's typed-question interface used by the judgment types. A noul answer is a truth probability from 0 to 1; answersToValue turns values at least 0.5 into true. Choice and score confidence comes from the adapter response; Noul confidence is 2 × |probability − 0.5|. Neither is a calibration guarantee. When a provider gives a choice or score its probabilities but no confidence, completeConfidence derives one with the formulas TypeSafe documents for Jev ((p − 1/n) / (1 − 1/n) for the chosen option; for a score, 1 minus the weighted distance from the most likely level over that of an even spread, floored at 0), and the decision receipt's confidence_source says which were derived. An answer { "type": "refusal" } never decodes to a value. The run ends escalated for a person (kind DECISION_REFUSED, "decision_refused", stage "decision", the decision's label and the workflow state at the node), so the case is kept, not lost to a failed run; the request's receipt is failed. A route with an unsure branch takes that branch instead ({ branch: null, taken, unsure: true, refused: true }). Either way the receipt lists the refused questions in refused, and answersToValue refuses a refusal (SystemOneError, reason refusal). A provider's outcome_unknown error, a request sent whose outcome was lost, ends the run escalated as an interrupted decision (kind "decision_interrupted", stage "decision"), booked at the cost the error carries and never asked again.
License
Apache-2.0. See the included LICENSE and NOTICE files.