— Documentation

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:

Install Configuration
terminal
# 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"
  }
}
Node version. A3 requires Node 20 or later. Stages are loaded by @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:

Workflow: Page summary Configuration
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:

Stage: Navigate Configuration
src/sites/default/main.stage.ts
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:

Stage: Extract Configuration
src/sites/example-com/extract.stage.ts
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:

CLI Configuration
terminal
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:

Run output Configuration
stdout
▸ 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:

  1. 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.
  2. match() has no side effects. It returns a boolean. Use locator.isVisible(), not locator.click().
  3. Mark blocking dialogs as overlay. Cookie banners, consent screens, promo modals. They get matching priority.
Don't hardcode sample inputs. During learning the agent sees a sample input. In production the runner gets real values from 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:

Agent: Summariser Configuration
src/agents/SummariserAgent.ts
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:

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.

Embed: basic Configuration
basic.ts
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.

HTTP server Configuration
server.ts
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 varPurpose
OPENAI_API_KEYUsed by agents configured with an OpenAI model.
ANTHROPIC_API_KEYUsed by agents configured with Claude models.
GOOGLE_API_KEYUsed by Gemini-backed agents (default learning model).
A3_COST_LIMIT_USDCaps cumulative LLM spend per run; halts on breach.
HTTP_PORTIf > 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:

ErrorMeaningSuggested action
NoStagesMatchErrorPage state not covered by any stage.Trigger a learning session, or fail loud.
MultipleStagesMatchErrorTwo stages claim the same state.Tighten matchers, or re-run learning to update them.
StageExecutionErrorrun() threw at runtime.Retry once, then route to learning.
CostLimitExceededErrorPer-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.

Next. Browse the API reference for the full surface, or jump back to Concepts if you want the bigger picture.