- Move 15 old workflow-* packages to legacy-packages/ (inactive, preserved for reference)
- Rename templates/ → examples/ for clarity
- Rewrite docs/architecture.md to reflect current uwf architecture
- Active packages remain in packages/: cli-uwf, uwf-agent-hermes, uwf-agent-kit, uwf-moderator, uwf-protocol, workflow-util
小橘 🍊(NEKO Team)
18 KiB
uwf — Architecture
Last updated: 2026-05-19
Overview
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each uwf thread step invocation runs one moderator→agent→extract cycle and exits.
The implementation lives in 6 active packages under packages/, plus two external CAS packages (@uncaged/json-cas, @uncaged/json-cas-fs). Legacy packages reside in legacy-packages/ and are not part of the active stack.
Package map
| Layer | Package | One-line role |
|---|---|---|
| Contract | @uncaged/uwf-protocol → uwf-protocol |
Shared TypeScript types (WorkflowPayload, StepNodePayload, ModeratorContext, WorkflowConfig, etc.). No runtime deps beyond @uncaged/json-cas-fs. |
| Shared infra | @uncaged/workflow-util → workflow-util |
Crockford Base32, ULID generation, createLogger, frontmatter parsing/validation. |
| Moderator | @uncaged/uwf-moderator → uwf-moderator |
JSONata-based graph evaluator: given a WorkflowPayload and ModeratorContext, returns the next role or $END. |
| Agent framework | @uncaged/uwf-agent-kit → uwf-agent-kit |
createAgent entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent: Hermes | @uncaged/uwf-agent-hermes → uwf-agent-hermes |
uwf-hermes CLI binary — spawns hermes chat, pipes prompt, captures session detail. |
| CLI | @uncaged/cli-uwf → cli-uwf |
uwf binary — thread lifecycle, workflow registry, CAS inspection, setup. |
External dependencies
| Package | Role |
|---|---|
@uncaged/json-cas |
Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
@uncaged/json-cas-fs |
Filesystem backend for json-cas. |
jsonata |
JSONata expression evaluator (used by uwf-moderator). |
commander |
CLI argument parsing (used by cli-uwf). |
dotenv |
Loads .env files for API keys. |
yaml |
YAML parse/stringify. |
Dependency graph
flowchart BT
subgraph External
jcas["@uncaged/json-cas"]
jcasfs["@uncaged/json-cas-fs"]
end
subgraph L0["Layer 0 — contract"]
protocol["@uncaged/uwf-protocol"]
end
subgraph L1["Layer 1 — shared"]
util["@uncaged/workflow-util"]
moderator["@uncaged/uwf-moderator"]
end
subgraph L2["Layer 2 — agent framework"]
kit["@uncaged/uwf-agent-kit"]
end
subgraph L3["Layer 3 — agent implementations"]
hermes["@uncaged/uwf-agent-hermes"]
end
subgraph L4["Layer 4 — CLI"]
cli["@uncaged/cli-uwf"]
end
protocol --> jcasfs
util --> protocol
moderator --> protocol
kit --> protocol
kit --> util
kit --> jcas
kit --> jcasfs
hermes --> kit
hermes --> jcas
cli --> protocol
cli --> util
cli --> kit
cli --> moderator
cli --> jcas
cli --> jcasfs
Workflow definition
Workflows are YAML files (not ESM bundles). uwf workflow put <file.yaml> parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the WorkflowPayload as a CAS node.
Example (examples/solve-issue.yaml):
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
systemPrompt: "You are a planning agent. Analyze the issue and create a step-by-step plan."
outputSchema:
type: object
properties:
plan: { type: string }
steps: { type: array, items: { type: string } }
required: [plan, steps]
developer:
description: "Implements code changes"
systemPrompt: "You are a developer agent. Implement the plan."
outputSchema:
type: object
properties:
filesChanged: { type: array, items: { type: string } }
summary: { type: string }
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
systemPrompt: "You are a code reviewer. Review the implementation."
outputSchema:
type: object
properties:
approved: { type: boolean }
comments: { type: string }
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
planner:
- role: "developer"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
Key properties:
roles— inline role definitions; eachoutputSchemais a JSON Schema (stored as its own CAS node on registration)conditions— named JSONata expressions evaluated against theModeratorContextgraph—Record<Role | "$START", Transition[]>— first matching transition wins;condition: null= fallback- No agent binding — agent selection is a deployment concern, configured in
config.yaml - No Zod — all schemas are JSON Schema, validated through
@uncaged/json-cas
Three-phase engine loop
Each uwf thread step runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in packages/cli-uwf/src/commands/thread.ts (cmdThreadStep).
┌─→ Phase 1: MODERATOR
│ Input: WorkflowPayload + ModeratorContext { start, steps[] }
│ Engine: JSONata conditions evaluated against the graph
│ Output: next role name | $END
│
│ Phase 2: AGENT
│ Input: thread-id + role (via argv)
│ Engine: agent-kit builds context from CAS chain, prepends
│ output format instruction to system prompt, spawns agent
│ Output: raw string (frontmatter markdown)
│
│ Phase 3: EXTRACT
│ Input: raw agent output + role's outputSchema
│ Engine: two-layer extract (frontmatter fast path → LLM fallback)
│ Output: CasRef to structured output node
│
│ Persist: StepNode { start, prev, role, output, detail, agent }
│ Update: threads.yaml head pointer
└─────────────────────────────────────────────────────────────────┘
Context types
Defined in packages/uwf-protocol/src/types.ts:
type StepContext = {
role: string;
output: unknown; // CAS node payload, expanded (not hash)
detail: CasRef;
agent: string;
};
type ModeratorContext = {
start: StartNodePayload; // { workflow: CasRef, prompt: string }
steps: StepContext[]; // chronological, oldest first
};
type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
};
Key properties
- Moderator — pure JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates
workflow.graph[currentRole]transitions in order, returns first match. - Agent — receives
AgentContextwith thread history + role system prompt + output format instruction. Raw output is frontmatter markdown. - Extractor — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
- Stateless — each
uwf thread stepis an atomic, self-contained operation. No in-memory state between steps.
Agent CLI protocol
Each agent is an external command invoked by uwf thread step:
<agent-cmd> <thread-id> <role>
Contract:
uwf thread stepdetermines the next role via the moderator- Agent CLI is spawned with
(thread-id, role)as positional args uwf-agent-kit(createAgent) handles the boilerplate:- Parses argv
- Loads
.envfrom storage root - Builds
AgentContextby walking the CAS chain fromthreads.yamlhead - Resolves the role's
outputSchemaand buildsoutputFormatInstruction - Calls the agent's
runfunction - Runs two-layer extract on the raw output
- Writes
StepNodeto CAS (output + detail + prev link) - Prints the new
StepNodeCAS hash to stdout
uwf thread stepreads stdout, updatesthreads.yamlhead pointer, re-evaluates moderator fordone- Exit 0 = success, non-zero = failure
Agent resolution priority: --agent CLI override → config.yaml per-workflow/role override → config.yaml defaultAgent.
Agent output format: frontmatter markdown (RFC #351)
Agents produce frontmatter markdown — YAML frontmatter for structured meta, followed by a markdown body for content:
---
status: done
next: reviewer
confidence: 0.9
artifacts:
- src/auth.ts
scope: role
---
## Implementation
Fixed the login redirect by updating the auth middleware...
The outputFormatInstruction (built by buildOutputFormatInstruction in uwf-agent-kit) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's JSON Schema.
Two-layer extract
Structured output extraction uses a two-layer strategy (uwf-agent-kit):
Layer 1: frontmatter fast path (frontmatter.ts)
- Parse YAML frontmatter from raw agent output (
parseFrontmatterMarkdown) - Validate required fields (
validateFrontmatter) - Build a candidate object from frontmatter fields (
status,next,confidence,artifacts,scope) store.put()the candidate against the role'soutputSchema- Validate with
json-casschema validation - If valid → return
outputHash(zero LLM cost)
Layer 2: LLM extract fallback (extract.ts)
If the fast path returns null (no frontmatter, invalid, or doesn't satisfy schema):
- Resolve extract model alias from config (
modelOverrides.extract→models.extract→defaultModel) - Call OpenAI-compatible chat completion with JSON mode
- System prompt: "Extract structured data matching this JSON Schema: ..."
- User message: the raw agent output
- Parse response,
store.put(), validate - Return
outputHash
Prompt injection
uwf-agent-kit prepends two pieces of context to the agent's system prompt:
- Deliverable format instruction — generated from the role's
outputSchema, tells the agent exactly what frontmatter fields to produce and the expected format - Scope constraint — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
CAS node types
Workflow
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
systemPrompt: "You are a planning agent..."
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
conditions:
notApproved:
description: "Reviewer rejected"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
StartNode
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
prompt: "Fix the login bug..."
StepNode
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against outputSchema)
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
agent: "uwf-hermes" # agent command used (plain string)
Chain structure
threads.yaml: { "01J7K9...4T": "8FWKR3TN5V1QA" }
│
▼
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → Workflow (CAS)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── prev ──→ StepNode (step 1)
│ │ └── prev: null
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(session turns)
└── agent: "uwf-hermes"
Storage layout
~/.uncaged/workflow/
├── cas/ # json-cas filesystem store (all CAS nodes)
├── config.yaml # Provider, model, agent configuration
├── threads.yaml # Active thread head pointers: threadId → CasRef
├── history.jsonl # Archived thread records
├── registry.yaml # Workflow name → CAS hash mapping
└── .env # API keys (loaded by dotenv)
Mutable state
Only three files carry mutable state:
| File | Contents |
|---|---|
threads.yaml |
Record<ThreadId, CasRef> — maps active thread IDs to head node hash |
history.jsonl |
Append-only log of completed threads (thread, workflow, head, completedAt) |
registry.yaml |
Workflow name → current CAS hash |
Everything else is immutable CAS content.
ID encoding: Crockford Base32
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
- CAS hash: XXH64 → 13-char Crockford Base32
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
Config (config.yaml)
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
CLI commands
Binary: uwf
Thread commands
| Command | Description |
|---|---|
uwf thread start <workflow> -p <prompt> |
Create a thread (StartNode → CAS, head → threads.yaml). No execution. |
uwf thread step <thread-id> [--agent <cmd>] |
Execute one moderator→agent→extract cycle. |
uwf thread show <thread-id> |
Show thread head pointer and done status. |
uwf thread list [--all] |
List active threads (--all includes archived). |
uwf thread steps <thread-id> |
List all steps in chronological order. |
uwf thread read <thread-id> [--quota <chars>] [--before <hash>] |
Render thread as human-readable markdown. |
uwf thread fork <step-hash> |
Fork a thread from a specific CAS node. |
uwf thread step-details <step-hash> |
Dump full detail node as YAML. |
uwf thread kill <thread-id> |
Terminate and archive a thread. |
Workflow commands
| Command | Description |
|---|---|
uwf workflow put <file.yaml> |
Register a workflow from YAML definition. |
uwf workflow show <id> |
Show workflow by name or CAS hash. |
uwf workflow list |
List registered workflows. |
CAS commands
| Command | Description |
|---|---|
uwf cas get <hash> |
Read a CAS node. |
uwf cas put <type-hash> <data> |
Store a node, print its hash. |
uwf cas has <hash> |
Check if a hash exists. |
uwf cas refs <hash> |
List direct CAS references. |
uwf cas walk <hash> |
Recursive traversal from a node. |
uwf cas reindex |
Rebuild type index from all nodes. |
uwf cas schema list |
List registered schemas. |
uwf cas schema get <hash> |
Show a schema by type hash. |
Setup
| Command | Description |
|---|---|
uwf setup [--provider --base-url --api-key --model --agent] |
Configure provider/model/agent (interactive if no flags). |
Toolchain
| Tool | Purpose |
|---|---|
| bun | Package manager + runtime |
| TypeScript | Type checking (strict mode) |
| Biome | Lint + format |
| vitest | Test runner |
Design decisions
| Decision | Rationale |
|---|---|
| YAML workflow definitions | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on workflow put. |
| Stateless single-step CLI | Each uwf thread step is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
| CAS-backed thread state | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
| JSONata moderator | Declarative condition expressions evaluated against thread history. No LLM cost for routing decisions. |
| Frontmatter markdown output | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
| Two-layer extract | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
| Prompt injection for format | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
| JSON Schema (not Zod) | Schemas are CAS-native data — storable, hashable, validatable through json-cas. No code generation, no runtime library dependency. |
| Agent as external command | Agents are independent CLI binaries (uwf-hermes, uwf-cursor). Swappable per workflow/role via config. No tight coupling to the engine. |
| No daemon | Process starts, does one step, exits. Simpler failure model, no connection management. |
| Crockford Base32 | Filesystem-safe, case-insensitive, readable, compact. |