Automation that reads like a spec, not a script.
Declarative workflows that chain HTTP calls, LLM reasoning, and notifications — with retries, timeouts and conditions handled for you.
Most automation starts as a 40-line script and ends as a 400-line one. The
business logic — fetch this, ask the model that, alert someone if it matters —
gets buried under retry loops, try/except ladders, timeout plumbing, and the
one API key that has to be threaded through six function calls.
Then it breaks at 3am and nobody can read it.
Relay pulls the plumbing out. What's left is the part you actually care about:
{
"name": "support-router",
"steps": [
{ "id": "classify", "use": "llm",
"with": { "system": "Reply with one word: billing, bug, urgent, or question.",
"prompt": "${{ input.ticket }}", "effort": "low" },
"retry": { "attempts": 2 } },
{ "id": "escalate", "use": "notify",
"if": "steps.classify == \"urgent\"",
"with": { "message": "Urgent ticket: ${{ input.ticket }}" } }
]
}Retries, backoff, timeouts, conditional branching, and value passing are the engine's job. Yours is the twelve lines above.
Needs Node 24+. No build step — Node runs the TypeScript directly.
git clone https://github.com/ramsai676/relay.git && cd relay
node src/cli.ts explain examples/support-router.json # show the step graph
node src/cli.ts run examples/support-router.json # run itWith no ANTHROPIC_API_KEY set, Relay uses a deterministic mock provider — so a
fresh clone runs end to end, offline, with zero configuration. Point it at the
real thing when you're ready:
export ANTHROPIC_API_KEY=sk-ant-...
npm install @anthropic-ai/sdk
node src/cli.ts run examples/lead-triage.jsonuse |
Does | Key with fields |
|---|---|---|
http |
Calls an API | url, method, headers, body, expectStatus |
llm |
Asks Claude | prompt, system, effort, maxTokens, model |
transform |
Reshapes data between steps | value |
filter |
Keeps matching items | items, where |
delay |
Waits | ms |
notify |
Posts a message | message, webhook |
Every step also takes if, retry, timeoutMs, and continueOnError.
${{ ... }} reads from three roots: input (run arguments merged over the
workflow's defaults), steps (any completed step's output), and env.
Conditions accept comparisons (==, !=, >, >=, <, <=), negation
(!path), and bare truthiness. An empty array or object is falsy, so
"if": "steps.search.results" reads the way you'd expect.
Inside a filter, where also sees item:
{ "id": "hot", "use": "filter",
"with": { "items": "${{ steps.leads }}", "where": "item.score >= 75" } }Retries use exponential backoff — backoffMs doubles per attempt, capped at
maxBackoffMs (30s default). Timeouts abort the step through an
AbortSignal, so a hung fetch is actually cancelled rather than left dangling.
A failed step stops the run. Mark it continueOnError and the run proceeds with
that step's output set to null, which downstream conditions can test for.
{ "id": "enrich", "use": "http",
"with": { "url": "https://api.example.com/enrich" },
"retry": { "attempts": 4, "backoffMs": 500, "maxBackoffMs": 8000 },
"timeoutMs": 10000,
"continueOnError": true }--dry-run executes the full graph — conditions, expression resolution,
validation — while http, llm, and notify return stand-ins instead of
reaching the network. Useful for checking a workflow's shape before it costs
anything.
node src/cli.ts run examples/lead-triage.json --dry-run --jsonimport { parse, run, MockProvider } from './src/index.ts';
const workflow = parse(await readFile('flow.json', 'utf8'), 'flow.json');
const result = await run(workflow, {
input: { ticket: 'Checkout is down' },
provider: new MockProvider(() => 'urgent'), // inject anything Provider-shaped
onEvent: (event) => console.error(event.type),
});
console.log(result.status, result.steps.at(-1)?.output);Provider is a one-method interface, so swapping in a different model, a
cached wrapper, or a recorded fixture is a constructor argument — not a fork.
Errors are collected, not thrown one at a time. A malformed workflow is fixable in a single pass:
Invalid workflow:
- name is required and must be a non-empty string
- steps[0].id must match ^[a-zA-Z_][a-zA-Z0-9_-]*$
- steps[0].use must be one of: http, llm, transform, filter, delay, notify
npm test29 tests across expression resolution, validation, and the engine — retries,
skips, abort semantics, continueOnError, dry runs, and provider injection.
They run offline via the mock provider.
Zero runtime dependencies. The engine, CLI, expression language, and test
suite use nothing but Node's standard library. @anthropic-ai/sdk is optional
and imported lazily — it's needed only if you actually call the llm step
against the real API.
Provider-abstracted. The default is claude-opus-5 with adaptive thinking
and configurable effort. MockProvider makes the whole system testable without
a network or a key.
JSON, not YAML. One less parser, one less dependency, and workflows stay diffable and machine-generatable.
MIT — see LICENSE.
Built by Ram Sai Kandagatla.