FASCICLE
BLUEPRINT
The rule Tiers Layout Anti-patterns Home Docs GITHUB
The agent blueprint

A standard architecture for fascicle apps.

Fascicle never dictates your app's shape — but the apps that stay easy to change all converge on the same one. The blueprint is that shape, distilled from the reference apps and from production consumers: one composition layer, markdown prompts, normalized module contracts, and boundaries that lint rules enforce instead of memory.

Building an agent on fascicle — or pointing a coding agent at the task? Start here. This page is the summary; the full spec lives in docs/blueprint.md.

src/
main.ts the shell: input in, run(), exit codes
flow.ts THE composition layer
engine.ts the only create_engine call site
types.ts zod schemas = stage contracts
state.ts raw scope keys only (chain needs none)
messages.ts format_* user-message builders
render.ts render_* output artifacts
prompts/ *.md system prompts, one per role
stages/ make_*_step factories, one per role
tools/ make_* Tool factories, limits.ts
services/ plain IO (git, db, http)
EVERY MODULE HAS ONE REASON TO EXIST AND A STRICT IMPORT CONTRACT.
01 · THE ONE RULE

Give the agent exactly one composition layer.

One file (call it flow.ts) shows the reader the entire topology, written in fascicle vocabulary only: chain, step, model_step, sequence, parallel, branch, loop. Everything that isn't shape (string formatting, IO, state transitions) lives in sibling modules and plugs in as plain functions.

Steps are plain values: anything with the same Step<i, o> type swaps for anything else. That plug-and-play property is only worth something if your app has a place where the blocks are visible. Scattered through business logic, the topology exists only in the reader's head. Gathered into one layer, the topology is the file — and swapping a block is editing one line of it.

flow.ts — the recurring stage idiom
return chain<PR, 'pr'>('pr')
.step('suggestions', ({ pr }, ctx) =>
ctx.call(reviewer, format_reviewer_message(pr)), { arm: reviewer })
.step('handoff', ({ pr, suggestions }, ctx) =>
ctx.call(builder, format_builder_message(pr, suggestions)), { arm: builder })
.output(({ handoff }) => handoff)
A stage is one chain binding: format from named bindings → call the model step → bind the typed payload. Nothing else. The arm metadata lets describe render each arm's subtree under its binding.
02 · PICK THE RIGHT TIER FIRST

Not every app should be a full composition.

Fascicle has three useful adoption tiers. Pick the smallest one that keeps your topology legible — and pick it per subsystem, not per project. A deterministic CLI can be tier 1 while its eval harness is tier 2.

TIER 1
Engine only

A plain async pipeline, where model calls are leaf functions behind a small app-owned port. Fascicle owns provider portability, schema enforcement, retries, and cost — you never adopt the composition algebra.

REACH FOR IT WHEN → the pipeline is deterministic code and the model is a subroutine.
TIER 2
Orchestrated loop

Fascicle's loop / sequence sits at the top, and nothing else does; each step delegates to a phase module. Trajectory, resume, abort, and cost caps fall out for free.

REACH FOR IT WHEN → an iterative build / check / critique loop wants resume and observability.
TIER 3
Composition-first

Fascicle owns the whole topology; you write leaf functions only. The full blueprint (the standard layout, stage factories, state readers) assumes this tier.

REACH FOR IT WHEN → a multi-stage model pipeline has branches, fan-out, or convergence, and stages should be swappable.
Over-composing is as real a failure as under-composing. A single-step "flow" that exists only to produce a span name is composition theater — a plain function is honest. Bury an if in a step body that another pipeline wants to compose around, and it should have been a branch.
03 · MODULE CONTRACTS

Each module has one reason to exist.

The names matter less than the contracts. What kills legibility isn't calling the file pipeline.ts instead of flow.ts — it's a message builder that's implemented inline in the flow, or a model_call that's hidden in a service.

MODULE OWNS MUST NOT CONTAIN
{{ c.name }} {{ c.owns }} {{ c.not }}
Prompts are markdown, not string literals
One .md file per role; the model is threaded as data (frontmatter is the role default). A prompt diff in review is a prompt diff, not a code diff with noise. Static instruction lives in markdown; dynamic assembly lives in messages.ts. Simple agents load them with define_agent; stage factories load the body as system.
Schemas are the contracts
One zod schema per model boundary in types.ts; pass it to model_step({ schema }) and the stage's output is the validated value itself, fully typed. Field constraints belong in .describe(); the prompt states the role. One home per rule — never both.
Named state goes through chain
chain threads a typed record, so most apps need no state.ts at all. Flows that reach for raw scope/stash/use hold string-keyed unknown; concentrate those casts in state.ts — key constants and typed read_* helpers, visible in one screenful.
Test with a stub engine, not a mocked flow
fascicle/testing ships the doubles (make_stub_engine and make_capture_engine) so tests run the real flow through the real run() with zero network. Canned responses route by the prompt's stable first line and validate through the caller's own schema, so fixtures can never drift.
04 · ANTI-PATTERNS

Observed in the wild.

Every one of these earned its place in the blueprint by hurting in a real codebase. The fix is in each card.

{{ a.n }}
{{ a.title }}
{{ a.body }}
FIX → {{ a.fix }}
05 · ENFORCE IT WITH RULES

Conventions decay. Machine-checked conventions don't.

The consumers that stayed clean all enforce the blueprint with ast-grep rules in CI: create_engine confined to engine.ts, no imperative loops in the composition layer, fascicle value imports confined to the files allowed to know about it.

Working, tested copies of all three rules ship in the reference app — copy the directory into your own app as a starting point. Each rule is a few lines, and each turns an architecture review comment into a build failure.

Copy the rules →
rules/create-engine-only-in-engine.yml
id: create-engine-only-in-engine
message: "create_engine may be called in exactly one file: src/engine.ts."
severity: error
language: typescript
files:
- src/**/*.ts
ignores:
- src/**/__tests__/**
- src/engine.ts
rule:
pattern: create_engine($$$)
Runnable in the repo: pnpm --filter ./examples/pr-improve check:rules

Before calling an agent app done.

☐One composition layer, fascicle vocabulary only; the header diagram matches the code.
☐create_engine in exactly one file; provider swap is one env var; disposal in finally.
☐Every model boundary has a zod schema; stages return Step<In, Out> from model_step (the envelope only where model_call is deliberate).
☐System prompts are markdown with frontmatter; dynamic content assembled in messages.ts.
☐Named state goes through chain; any raw scope casts live only in state.ts readers. Tools re-parse inputs and share one confinement helper.
☐The flow runs end to end against a stub engine; boundaries are enforced by lint rules, not memory.
Read the full blueprint → Open the docs
FASCICLE

Composable TypeScript toolkit for agentic workflows. Compose LLM calls, tools, and plain functions into a Step<i, o>.

Apache-2.0 · © 2026