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 enforced by lint rules 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 scope keys + readers (casts live here) messages.ts format_* user-message builders render.ts render_* output artifacts prompts/ *.md system prompts, one per role stages/ make_*_call 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 — that the reader opens to see the entire topology, written in fascicle vocabulary only: sequence, parallel, branch, loop, scope, step, model_call. Everything that is not shape — string formatting, IO, state transitions, extraction — lives in sibling modules and is plugged 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
const reviewer_subflow: Step<unknown, ReadonlyArray<Suggestion>> = sequence([
use([K.PR], (s) => format_reviewer_message(read_pr(s))),
reviewer_call,
step('extract_suggestions', (r) => r.content.suggestions),
])
Format from named state → call the model → extract the typed payload. Nothing else. Readers learn to see the three-step sequence as one unit.
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; 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 at the top and nothing else; 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. An if buried in a step body that another pipeline wants to compose around should be a branch.
03 · MODULE CONTRACTS

Each module has one reason to exist.

The names matter less than the contracts. What kills legibility is not calling the file pipeline.ts instead of flow.ts — it is a message builder implemented inline in the flow, or a model_call 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_call({ schema }) and read r.content fully typed. Field constraints belong in .describe(); the prompt states the role. One home per rule — never both.
Quarantine the casts
Scope state is a string-keyed map of unknown; something has to cast. Concentrate all of it in state.ts — key constants and typed read_* helpers, adjacent, visible in one screenful.
Test with a stub engine, not a mocked flow
The Engine type is a small interface — a scripted stub is ~40 lines and runs the real flow through the real run() with zero network. Route canned responses by the prompt's stable first line; validate fixtures through the caller's own schema so they can never drift.
04 · ANTI-PATTERNS

Observed in the wild.

Every one of these earned its place in the blueprint by the pain of its presence 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, GenerateResult<Out>>.
System prompts are markdown with frontmatter; dynamic content assembled in messages.ts.
Scope-state 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