Build your first A3 automation in ten minutes.
This guide walks through installing A3, writing a workflow, running it from the CLI, and embedding it inside an existing Node application. By the end you'll have a runnable scrape pipeline and a pattern you can extend to your own sites.
01 · Install
A3 ships as a set of npm packages under the @athree scope. Create a fresh project and add the runner and CLI:
# In a new directory mkdir my-automation && cd my-automation npm init -y npm i @athree/runner @athree/cli @athree/helpers npm i -D typescript @types/node
A3 uses native ESM and decorators. Add a minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"experimentalDecorators": true,
"outDir": "out"
}
}@athree/module-loader,
which uses Node's loader hooks to enable .md imports and cache-busting during dev.
02 · Your first workflow
A workflow is a single file that declares the contract. Create src/scrape.workflow.ts:
import { defineWorkflow } from '@athree/runner'; import { z } from 'zod'; export const InputsSchema = z.object({ url: z.string().url(), }); export const OutputsSchema = z.object({ title: z.string(), headings: z.array(z.string()), }); export default defineWorkflow({ title: 'Page summary', description: 'Extract a page title and its headings', inputsSchema: InputsSchema, outputsSchema: OutputsSchema, rootDir: 'src', sitesDir: import.meta.dirname + '/sites', });
Stages live under sites/<hostname>/. The runner sets ctx.site
to the slugified hostname after the first navigation, so a folder per service is all
the routing you need. Start with a default/ folder for the global navigate stage:
import { defineStage } from '@athree/runner'; export default defineStage({ title: 'Navigate to website', description: 'Opens the target website', run: async (ctx) => { await ctx.page.goto(ctx.inputs.url, { waitUntil: 'domcontentloaded' }); const currentUrl = new URL(ctx.page.url()); ctx.site = currentUrl.hostname.replace(/\./g, '-'); }, });
Then add an extract stage for the specific site you're targeting:
import { defineStage } from '@athree/runner'; export default defineStage({ title: 'Extract', success: true, match: async (ctx) => { return await ctx.page.locator('h1').first().isVisible(); }, run: async (ctx) => { ctx.outputs.title = await ctx.page.title(); ctx.outputs.headings = await ctx.page .locator('h1, h2') .allInnerTexts(); }, });
03 · Run it
Run via the CLI — no build step needed; @athree/cli uses Node loader hooks to import TypeScript directly:
npx a3 run src/scrape.workflow.ts \
--inputs '{"url": "https://example.com"}'The CLI spawns a runner process, executes stages in order, and prints validated outputs:
▸ workflow: Page summary ▸ stage: Navigate ✓ 412ms ▸ stage: Extract ✓ 88ms ▸ result: success { "title": "Example Domain", "headings": ["Example Domain"] }
04 · Writing stages well
Three rules cover most authoring decisions:
- Each stage owns one logical state. Don't fold "navigate, then validate" into one stage — the validate step belongs to whichever stage matches the resulting page.
match()has no side effects. It returns a boolean. Uselocator.isVisible(), notlocator.click().- Mark blocking dialogs as
overlay. Cookie banners, consent screens, promo modals. They get matching priority.
ctx.inputs.
Always read ctx.inputs.fieldName rather than copying values from the sample.
05 · Adding an agent
Agents are LLM-powered actors. Define one with defineAgent, register it on the workflow's agents array, then resolve it inside a stage:
import { AskUserQuestionTool, defineAgent, FailTool, } from '@athree/runner'; export const SummariserAgent = defineAgent({ id: 'summariser', title: 'Summariser', description: 'Produces a one-sentence summary of page text', icon: 'fas fa-file-lines', systemPrompt: () => ({ content: `You write terse, factual summaries.`, }), tools: [AskUserQuestionTool, FailTool], });
Register it on the workflow:
export default defineWorkflow({ // … agents: [SummariserAgent], });
06 · Learning a new site
Open the project in the A3 manager UI and start a learning session for the workflow. A3 then runs a learning loop and writes results back to your files:
- Take a page snapshot, match against existing stages.
- If
NoStagesMatch— A3 creates a new stage in place. - If
MultipleStagesMatch— A3 updates matchers to disambiguate. - Update playbook.md for the site as new flow states are observed.
- Stop when a stage marked
success: trueexecutes.
Learning artifacts land on disk under sites/<site>/ next to the playbook. Diff them, edit them by hand, commit them.
Re-running learning later patches the same files rather than starting over.
07 · Embed inside your app
The CLI is convenient for one-shot runs, but most teams call A3 from their existing services.
Construct an App, wire its WorkflowRunner, and call it from any handler.
import { App } from '@athree/runner'; import ScrapeWorkflow from './scrape.workflow.js'; const app = new App(); await app.run(); const result = await app.workflowRunner.runWorkflow(ScrapeWorkflow.id, { inputs: { url: 'https://example.com' }, }); console.log(result.outputs); await app.shutdown();
08 · Trigger from an HTTP server
Construct the App once at boot, then reuse it across requests. A3 manages browser pools and
LLM clients internally — a per-request instance would tear those down on every call.
import express from 'express'; import { App } from '@athree/runner'; import ScrapeWorkflow from './scrape.workflow.js'; const app = new App(); await app.run(); const server = express(); server.use(express.json()); server.post('/scrape', async (req, res) => { try { const result = await app.workflowRunner.runWorkflow( ScrapeWorkflow.id, { inputs: req.body }, ); res.json(result); } catch (err) { res.status(500).json({ error: String(err) }); } }); server.listen(3000);
09 · From a job queue
For longer-running automations, drive A3 from a queue (BullMQ, SQS, Temporal). Each worker holds a single
App instance and processes jobs serially or with a small concurrency cap to keep browser memory bounded.
const a3 = new App(); await a3.run(); const worker = new Worker('scrape-jobs', async job => { return await a3.workflowRunner.runWorkflow(ScrapeWorkflow.id, { inputs: job.data, }); }, { concurrency: 2 });
10 · Deploy
The repo ships a Dockerfile suitable for production. The image bundles Playwright's bundled Chromium so cold-start is fast.
# build & run
docker build -t my-automation .
docker run --rm \
-e OPENAI_API_KEY=$OPENAI_API_KEY \
-e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
my-automation a3 run out/scrape.workflow.js| Env var | Purpose |
|---|---|
| OPENAI_API_KEY | Used by agents configured with an OpenAI model. |
| ANTHROPIC_API_KEY | Used by agents configured with Claude models. |
| GOOGLE_API_KEY | Used by Gemini-backed agents (default learning model). |
| A3_COST_LIMIT_USD | Caps cumulative LLM spend per run; halts on breach. |
| HTTP_PORT | If > 0, the runner exposes its WS protocol for the manager UI. |
11 · Errors & recovery
The runner exposes its routing signals as typed errors. Catch them at the integration layer to retry or escalate:
| Error | Meaning | Suggested action |
|---|---|---|
| NoStagesMatchError | Page state not covered by any stage. | Trigger a learning session, or fail loud. |
| MultipleStagesMatchError | Two stages claim the same state. | Tighten matchers, or re-run learning to update them. |
| StageExecutionError | run() threw at runtime. | Retry once, then route to learning. |
| CostLimitExceededError | Per-run LLM spend cap reached. | Inspect the LLM log middleware output. |
12 · Observability
Every run produces a structured log of stage matches, executions, agent calls, tool calls and LLM token usage.
Two middlewares ship by default — LlmLogMiddleware records prompts and completions; LlmUsageMiddleware aggregates tokens
and cost. Wire your own to forward to OpenTelemetry, Datadog, or stdout.