Best Practices
The other guides in this section show how each mechanism works. This page is about judgment: which concept to reach for, why, and how to keep things clean as they grow. It is the knowledge every developer — and every agent — should carry into Loopstack before writing the first line. None of it is API detail; it is the professional shape of clean Loopstack code.
The through-line for all four building blocks is the same: optimize for legibility. Someone reading your code should understand the system without running it.
Workflows
A workflow is a state machine, and its highest virtue is legibility. Someone scanning your transitions should understand the process without executing it.
Think in narratives, not steps
A workflow’s places (states) and transitions are a story. Name them so the story reads top to bottom:
start → fetch_data → summarize → waiting_for_approval → publish → endGood place names describe where the workflow is; good transition method names describe the step being taken (fetchData, summarize, publish). When the state machine reads like prose, routing bugs become visible and onboarding is free. Avoid abstract names (step1, process2, handlerA) — name things for what they do, never for an invented taxonomy.
Scripted by default; agentic only when the path is open-ended
Loopstack gives you two modes for doing work:
- Scripted — you write the transitions. The path is fixed and deterministic.
- Agentic — an
AgentWorkflowruns an LLM tool-calling loop and decides the path at runtime.
Default to scripted. Determinism is cheaper, faster, testable, and debuggable. Reach for an agent only when the sequence of steps genuinely cannot be known in advance — open-ended exploration, research, or tasks where the next action depends on what the previous one discovered.
The best designs are usually hybrid: a deterministic workflow that scaffolds the run (validate input → prepare context → contained agent step → validate output → publish). The agent gets autonomy only where autonomy is needed; everything around it stays predictable. An agent is a tool you place inside a clear process, not a replacement for having one.
Thin transitions, fat tools
Logic has two homes, and putting it in the wrong one is the most common source of unmaintainable workflows.
- Transitions orchestrate. They decide what happens next and move state. Keep their bodies short — read inputs, call a tool or a sub-workflow, write state, done.
- Tools do the work. Domain logic belongs in a tool: one tool, one responsibility, a narrow Zod input schema, and a clear description.
Why this split pays off: a tool is reusable across workflows, independently testable, and — because tools carry a description and schema — it doubles as an LLM function with zero extra work. Logic inlined in a transition is none of those things.
// Avoid: business logic living in the transition
@Transition({ from: 'ready', to: 'done' })
async process(state: MyState) {
const rows = await db.query(...); // domain logic
const filtered = rows.filter(...); // domain logic
this.assignState({ count: filtered.length });
}
// Prefer: the transition orchestrates, the tool does the work
@Transition({ from: 'ready', to: 'done' })
async process(state: MyState) {
const result = await this.fetchActiveUsers.call({ since: state.since });
this.assignState({ count: result.data.length });
}Know your three data surfaces: state, result, documents
Workflows expose data in three places. Choosing the wrong one is the subtlest and most common smell.
| Surface | What it is | Use it for |
|---|---|---|
| State | Internal working memory, persisted across pauses | Intermediate values the workflow needs to do its job |
| Result | The workflow’s published output | The contract consumers and parent workflows read |
| Documents | Typed objects rendered in Studio | Everything the user should see |
The rules of thumb:
- Keep state minimal. It is scratch space, not an output channel. If a value only matters mid-run, it stays in state and never leaves.
- Design the result as a deliberate contract. It is the public API of your workflow — what a parent receives in a callback, what a consumer depends on. Publish a clean, intentional shape with
setResult/assignResult. Never dump raw internal state into the result. - Documents are for humans. If a value needs to be seen, save a document. If it needs to be consumed by code, it belongs in the result. They are not interchangeable.
Extract a sub-workflow for a reason, not by reflex
One readable workflow beats five fragmented ones. Split only when there is a concrete payoff:
- Reuse — the same unit of work is needed in more than one place.
- Independent lifecycle or UI — the unit pauses for its own input, or should render as its own panel/link in Studio.
- Fan-out — you need to run the unit in parallel or in sequence over a list.
If none of those apply, keep it inline. Premature decomposition trades one clear flow for orchestration overhead. And when you do split, name the sub-workflow for what it accomplishes — never sub-workflow-a or type-2-handler.
Pause only for genuinely external events
A wait transition pauses the workflow until something outside it happens: a user clicks a button, a sub-workflow completes, an API calls back. That is the only reason to wait.
Never use waiting to poll or to “give something time.” If you find yourself waiting and re-checking, the work should be a callback instead. Waiting is for handing control out and getting it back — not for busy-looping.
Keep guards pure
Guards decide routing when several transitions share a from place. Treat them as pure predicates over state: they read, they return a boolean, they do nothing else.
A guard that mutates state, calls a tool, or has a side effect makes routing impossible to reason about and impossible to test in isolation. Compute the decision inputs in a transition, store them in state, and let the guard read them. The guard is the single, visible source of routing truth.
Choose an error strategy by failure type
Not all failures are equal. Match the recovery mode to the kind of failure:
| Failure kind | Strategy |
|---|---|
| Transient (network blip, rate limit) | Auto-retry |
| User-fixable (bad input, missing approval) | Error place / manual retry — surface it and let the user correct course |
| Fatal (programmer error, impossible state) | Fail loudly — don’t paper over a bug |
Then internalize the rollback reality: when a transition throws, its state mutations roll back — but external side effects do not. A half-sent email or a half-written file stays half-done. So design transitions to be safe to re-run: make side effects idempotent, or structure them so a retry can’t double-apply them. Surface failures the user should see as an ErrorDocument rather than letting them disappear into logs.
Treat schemas as contracts
Every boundary — workflow args, state, transition input — takes a Zod schema. Use them. A schema validates at runtime and documents the shape for the next reader (human or LLM). Validated boundaries turn a class of silent, late failures into loud, early ones. The few lines a schema costs are repaid the first time bad data is rejected at the door instead of corrupting a run three steps later.
Templates present; code decides
Handlebars templates are for assembling text — prompts, markdown, user-facing copy. Keep decisions out of them. Branching, lookups, and computed values belong in TypeScript where they can be read, typed, and tested; the template just renders the result. A template that contains business logic is logic hidden where no test will find it.
Conventions that keep workflows clean
These are project conventions; the reasons behind them are what matter:
- Transitions return nothing — mutate via setters (
assignState/setState,assignResult/setResult). Returning a value throws at runtime. This keeps state changes explicit and uniformly rollback-able. asynconly when the body awaits. Don’t decorate a synchronous transition withasync— the linter strips unusedasync, and the intent stays honest.- No abstract base classes for workflow patterns. Compose with tools and sub-workflows instead of inheritance hierarchies; the framework is built for composition.
- Fix root causes, not symptoms. When a workflow misbehaves, find the wrong assumption — don’t add a guard or a retry to mask it.
- No compatibility shims. When a contract changes, update every caller. Don’t leave deprecated re-exports behind.
Tools
A tool is a reusable unit of logic with two audiences: your own code and the LLM that may call it as a function. Design for both.
One tool, one responsibility
A narrow tool is reusable, composable, and — crucially — easy for an LLM to call correctly. A tool that does five things is hard to reuse and hard for a model to use right, because the model has to guess which of its behaviors you want. If a tool’s description needs the word “and” several times, it should probably be several tools.
The description and schema are the LLM’s instructions
For a tool that an LLM can call, the description and Zod schema are not just documentation — they are the only thing the model reads to decide when and how to call it. Write the description to state what the tool does and when to reach for it; give each field a clear, narrow type. Vague descriptions produce wrong calls. This is effort that pays back every time the model uses the tool.
Keep tools stateless and self-contained
Everything a tool needs arrives through its args and the ctx it is handed. A tool should never reach into a particular workflow’s internal state — that couples it to one caller and breaks the reuse that justified making it a tool. Same inputs should mean same behavior.
Make tools safe to re-run
Tools get retried, and the rollback rules cover the calling transition’s state — not a tool’s external side effects. Design side effects to be idempotent so a retry can’t double-apply them (don’t charge the card twice, don’t append the row twice).
Return a clean result; fail with actionable errors
A tool’s result is a contract for both code and the LLM, so return a typed, intentional shape. And when a tool errors during agent use, the message is fed back to the model so it can self-correct — so an error should say what went wrong and how to fix it, not just “failed.”
Documents
Documents are how a workflow talks to the user. They are a communication surface, not a data store.
Documents are for humans, not for code
If a value exists so that code can use it, it belongs in state or result. Save a document only for what the user should see in Studio. Using a document as a place to stash data your workflow will read back later is the most common misuse — it couples your logic to the UI and clutters the run.
Reuse built-in document types before creating a custom one
Markdown, chat messages, links, and errors already cover most needs. Create a custom document only when the rendering genuinely differs — a form, a code editor, a structured display. A custom document is UI you have to maintain; don’t add one just to hold plain data that a built-in type would render fine.
Emit documents to keep a run legible
A run a user can follow in Studio is one they can trust and debug. Save the meaningful intermediate outputs, not only the final one — but don’t spam: a document the user doesn’t need is noise that hides the ones they do.
Modules & Studio Apps
Modules are how you organize capability; @StudioApp is the user-facing boundary on top of that organization.
Organize modules by feature, not by type
Group the workflows, tools, and services of one capability into one module. Don’t create a “tools module” and a “workflows module” — that scatters a single feature across the codebase. A module should read as one cohesive thing the system can do.
Import only what you use
A module’s imports are its honest dependency list. Pull in a provider module (LLM, OAuth, sandbox, secrets) where the capability is actually needed and nowhere else. A lean, accurate import graph is one you can reason about; a kitchen-sink module hides what really depends on what.
@StudioApp is a deliberate boundary, not a default
@StudioApp marks a module as a launchable application in Studio. Apply it only to modules meant to be run by users — without it, workflows still exist as providers but stay out of the UI, which is often exactly right for internal building blocks. Keep each app focused: a coherent set of related workflows under one clear title, not a dumping ground.
Register a shared tool once, at the right level
Tools resolve through dependency injection at runtime, so a tool registered in a module is available to every workflow and agent in scope. Register a shared tool once where its consumers can reach it, rather than re-declaring it per workflow — that is what lets an agent and a scripted workflow use the same tool without duplication.
Related
- Creating Workflows · Tools · Documents · Modules · Studio App Config
- State Management · Error Handling · Sub-Workflows · Dynamic Routing
- Agent Workflows — when and how to go agentic