Warning
Early and in active development. Expect breaking changes on any commit. The API, the DSL and the schema are all still moving. Pin a commit if you build on it.
lemongraph is a durable decision core for long-horizon agents: agents that chase one goal over days or weeks. They sleep most of the time and wake when something happens.
An agent that runs for weeks must reach the same decision from a given state every time it wakes. A model asked to decide afresh will not: identical state can produce different moves that compound. lemongraph makes the decision a pure function of recorded state and the incoming event. A graph of milestones resolves the next step: advance, wait or fail. The model is confined to where language is the work, interacting and making local decisions. It does not move the job forward or grade its own work. Each milestone settles on evidence external to the model, an inbound event or a verified tool result, not the model's claim of success.
Safety in agents usually lives at the edges: input filters, output checks, a gate on which tool can fire. Those keep a single step in bounds. They cannot tell you whether the goal is actually being reached or whether the agent is fooling itself about progress. lemongraph puts the guardrail one level up, on the goal-achievement logic. The decision to advance, wait or fail is a pure function of recorded state and the event, so it reproduces and audits. The model runs where language is the work. It cannot move the job forward or sign off on its own output. A milestone advances only on evidence from outside the model, an inbound event or a verified tool result. Safety is the default path here, not a layer you bolt on.
You only need Docker.
docker compose upThat builds the workspace, starts Postgres and runs migrations. Then it boots the engine and the UI:
- UI: http://localhost:3000
- Engine API: http://localhost:3002
The stack boots with no secrets. To run the agent steps and hit the API you set two things (full list under Configuration): OPENAI_API_KEY for the model and API_TOKEN_SECRET so the API accepts a bearer token. Export them first and the engine picks them up:
export OPENAI_API_KEY=sk-...
export API_TOKEN_SECRET=any-long-random-string
docker compose upEvery call is authenticated. Mint a token, start a job and send it an event. Starting a job and ingesting an event don't call the model. This works without an OpenAI key:
export API_TOKEN_SECRET=devsecret
docker compose up -d
# mint a bearer token signed with the same secret
TOKEN=$(docker compose exec -T engine pnpm --filter engine token | tail -n1)
# start a job on the seeded onboarding playbook
curl -s -X POST http://localhost:3002/v1/jobs \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"playbook_key": "onboarding", "key": "job-1"}'
# send it an event that clears the first milestone
curl -s -X POST http://localhost:3002/v1/events \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"key": "job-1", "action": "id_uploaded"}'Both calls return 202. Open the UI at http://localhost:3000 to watch the job move.
Working on the code? You need Node 22 and pnpm 9. Copy the env examples, start just Postgres in Docker and run the dev servers on the host:
cp apps/engine/.env.example apps/engine/.env.local
cp apps/app/.env.local.example apps/app/.env.local
pnpm install
docker compose up -d postgres
pnpm db:migrate # apply drizzle migrations
pnpm --filter engine dev # engine on http://localhost:3002
pnpm --filter app dev # UI on http://localhost:3000Common checks:
# from apps/engine
pnpm test # vitest with coverage
pnpm typecheck # tsgo --noEmit
pnpm simulate # run playbook scenarios, see Simulate a playbook
# from the repo root
pnpm test # turbo test across the workspace, single concurrency
pnpm typecheck # oxlint type-aware check
pnpm infra:down # stop the stackThe loop is EVENT -> DECISION -> TASK -> CONVERSATION -> (next EVENT). This repo owns event intake, the decision and durable task scheduling. A conversation (talking to a person over a channel) is a separate step that calls back with the next event.
You build a playbook: a name, top-level instructions for the whole job and a graph of tasks.
Each task carries its own instructions, a success condition and the integrations it is allowed to touch. needs edges wire them from the trigger through to done.
- A playbook is a DAG of milestones. Each milestone carries a condition written in a small SQL-flavored DSL over event facts and properties, for example
appointment_scheduled AND NOT user_replied WITHIN 24h.needsedges own the ordering. The decision is just: which milestone is active and did its condition settle. - Conditions settle to a verdict. A
Monitorscoped to the active milestone consumes events and returnsTRUE,FALSEorUNKNOWN.TRUEcompletes the milestone,FALSEknocks it out,UNKNOWNstays and schedules a settlement tick.WITHIN <dur>is the only temporal operator: reachTRUEinside the window or the milestone times out. The job is done when every goal is reached. It fails only when no path to a goal survives the knocked-out milestones. - Branches merge with
allorany. When two branches feed the same milestone you choose how they join.allwaits for every branch, so knocking one out with no alternative fails the job.anyneeds just one branch to land, so the job routes around a dead branch and keeps moving toward the goal.allis the default. It is what separates a diamond that only ran two branches in parallel from one that survives losing either. When ananymerge resolves, the engine drops the losing branch so the job stops spending on a path that can no longer matter. - The decision is pure. The
JobGraphaggregate keepsdone/knocked/monitorstate and advances vianext(now, event?). Given the same state and event, it makes the same move every time. - The conversation agent is built for the frontier. When the job needs a person contacted, the engine composes an agent over the frontier: the next best step and the tasks available alongside it. The agenda, tools, delegate agents for external systems like a CRM and attached skills all come from that set. The person keeps latitude over which task to move. The agent acts only through the tools it is given and reports back an outcome event. Integration work counts as done only when the integration confirms.
- A milestone settles on external evidence. The model does not grade its own work. A verdict is formed from facts that come from outside the model: an inbound event the world sends back or the verified result of a tool call. The agent can claim it sent the email or booked the meeting. That claim moves nothing on its own. The milestone advances only when a confirming event or a tool result proves it happened. What the model says and what the graph records stay separate.
- Agents are in a catalog and delegate. Rather than one agent carrying every tool, a conversation agent hands a CRM change to the matching integration agent in plain language. Each agent carries only what its step needs.
+-------------------------------------------------+
| communication layer |
| inbound events in, messages out |
| (HTTP ingest, channels, reply routing) |
+-------------------------------------------------+
| ^
v |
+-------------------------------------------------+ +---------------------+
| agents | | engine |
| a conversation agent composed per step, |<--->| decides the next |
| equipped with exactly the tools, delegates | | best step and |
| and skills that step needs | | keeps it durable |
+-------------------------------------------------+ +---------------------+
| ^
v |
+-------------------------------------------------+
| memory layer |
| event log + knowledge graph + profile |
+-------------------------------------------------+
The core decides the next best step: it reads the job's state, evaluates the active milestone against the incoming event and picks what comes next. The durable engine sits to the side because it is a swappable seam. It only makes time and ordering survive restarts: task due-times and WITHIN window settlement become durable timers. Each job advances one step at a time. That backend is an adapter. The durable runtime can change without touching the pure JobGraph that makes the decision.
Every milestone carries one condition. The engine reads it against the events a job receives and settles it to one of three verdicts: TRUE completes the milestone, FALSE knocks it out and UNKNOWN means keep waiting. You write conditions in a small SQL-flavored language over the facts your events carry.
Start with a fact. A bare fact name is true once that event has arrived:
id_uploaded
Compare a value. A fact carries a value and named properties. Compare them with ==, >, >=, < or <=. Strings are double-quoted and work only with ==:
amount > 1000
status == "won"
Reach into a property with a colon:
order:total >= 500
Combine with AND, OR and NOT:
appointment_scheduled AND NOT escallated_to_human
Count how many times a fact has arrived:
COUNT(reminder_sent) >= 3
Match against a set with IN:
plan IN ["pro", "enterprise"]
Put a clock on it with WITHIN. The condition has to reach TRUE inside the window. If the window closes first it settles FALSE and the milestone times out. A duration is a number and a unit: s, m, h or d:
appointment_scheduled AND NOT appointment_cancelled WITHIN 24h
A condition with no WITHIN gets a default 30 day window. Write an explicit WITHIN for a shorter or longer deadline (up to 180 days). Every milestone carries a deadline, so nothing waits forever. WITHIN is a timeout, not a priority: it decides when a milestone knocks out, never which available milestone the engine works on first.
What this means for the engine: a Monitor scoped to the active milestone feeds each new event through the condition. While the answer is still open it returns UNKNOWN and the job sleeps until the next event or the window deadline. The moment the events make the condition clearly true or false, the milestone advances or knocks out and the job moves to its next best step.
Before a playbook runs for real you can prove it out against scripted events. A scenario file exports a playbook and a list of scenarios: named event scripts with the outcome each one should reach. pnpm simulate runs every script through the pure decision core on a virtual clock. Timeouts fire and WITHIN windows close exactly as they would in production. No model is called and nothing touches the network or the database.
# from apps/engine
pnpm simulate # run every *.scenario.ts under src/
pnpm simulate telecom # only scenario files with "telecom" in the path
pnpm simulate --trace # show every turn: events, ticks, verdicts, emitted tasks
pnpm simulate onboarding --coverage # which milestones and OR branches got exercised (one file)
pnpm simulate --json # emit { results, coverage } as JSONA scenario pins a base time, replays events at fixed offsets and asserts where the job lands:
export const scenarios: Scenario[] = [
{
name: 'customer-onboarding',
baseMs: T0,
events: [
{ action: 'record_created', atMs: T0 + DAY },
{ action: 'meeting_scheduled', atMs: T0 + 2 * DAY },
{ action: 'account_activated', atMs: T0 + 5 * DAY },
],
expect: { status: 'done', done: ['collectIdentity', 'bookCall', 'activateAccount'] },
},
];The exit code is non-zero when any scenario misses its expectation. The command slots straight into CI.
Two authenticated entry points drive the loop. Both take a bearer token and return 202 Accepted.
Start a job:
POST /v1/jobs
{
"key": "job-123",
"playbook_key": "outreach",
"subject": { ... },
"contacts": [ ... ]
}Creates a job from a playbook. key is your idempotency handle: a repeat returns 409. An unknown playbook_key returns 404.
Ingest an event:
POST /v1/events
{
"key": "job-123",
"action": "appointment_scheduled",
"properties": { ... },
"idempotency_key": "evt-1"
}Delivers an event to a job by key. The engine advances that job's decision and returns 202. An unknown job key returns 404. idempotency_key deduplicates a retried delivery.
Turborepo with pnpm workspaces. The service is apps/engine; the app is apps/app. Shared logic lives in packages/*.
apps/engine/src/
api/ inbound HTTP: /events ingest, bearer auth, rate limit, error taxonomy
runtime/ handle generator (EVENT -> DECISION -> TASK as effects) + effect executor
ports/ structural interfaces: JobStore, PlaybookStore, TaskEmitter, OutboundGateway, ...
adapters/ driven port impls: postgres stores, durable workflow, openai, agent-registry, channels
composition/ wires stores, memory and the agent registry into the backend
database/ drizzle schema + client
config/ env config
main.ts composition root: build deps, start runtime, mount api, wire shutdown
packages/
compiler/ condition DSL (parse, Monitor, AST, Verdict), domain value types, JobGraph
agents/ agent catalog, conversation agent, per-step composition, tools, skills
memory/ event log, knowledge graph, profile store
channels/ inbound/outbound messaging (email over Resend)
integrations/ external-system integrations
observability/ observability helpers
@lemongraph/compiler is the base layer. It never imports the sibling service packages. Dependency-cruiser enforces that boundary.
Each app reads its config from the environment. Every workspace ships a .env.example you can copy to .env.local. docker compose sets DATABASE_URL, PORT and ENGINE_URL for you. You only pass the secrets you want.
The engine variables:
| Variable | Default | What it does |
|---|---|---|
DATABASE_URL |
postgresql://postgres:postgres@127.0.0.1:5455/lemongraph |
Postgres connection. docker compose points this at the postgres service. |
PORT |
3002 in the examples |
Port the engine API listens on. The UI proxies to 3002. Keep it there. |
API_TOKEN_SECRET |
empty | HMAC secret for API bearer tokens. While it is empty every /v1 call is rejected with 401. Set it to use the API or the UI. |
OPENAI_API_KEY |
empty | Key for the model and embeddings. The stack boots without it. Agent steps fail until it is set. |
OPENAI_MODEL |
gpt-4o |
Model the conversation agents run on. |
NANGO_SECRET_KEY |
empty | Secret for integration agents. Needed only if a job talks to an external system. |
Email over Resend is optional. Set these only if a playbook sends mail:
| Variable | What it does |
|---|---|
RESEND_API_KEY |
Resend key for sending email. |
RESEND_WEBHOOK_SECRET |
Verifies inbound Resend webhooks. |
MAIL_FROM |
From address for outbound email. |
REPLY_DOMAIN |
Domain that replies are routed on. |
REPLY_TOKEN_SECRET |
Signs the reply routing tokens. |
The UI variables:
| Variable | What it does |
|---|---|
VITE_ENGINE_URL |
Leave empty to reach the engine through the vite dev proxy, same origin and no CORS. |
VITE_ENGINE_TOKEN |
Bearer token the UI sends to the engine. It has to be a token signed with API_TOKEN_SECRET. |
Tokens are HMAC-signed and carry a lemongraph_sk_ prefix. Mint one with docker compose exec engine pnpm --filter engine token or pnpm --filter engine token on the host. See Try it.
Issues and pull requests are welcome. Read CLAUDE.md first: it is the source of truth for the architecture, the core concepts and the working conventions. Match the existing style and keep changes surgical. Run pnpm test and pnpm typecheck before you open a PR.
Commits follow Conventional Commits, enforced by commitlint: a commit-msg hook checks each commit and CI checks the PR title.
GNU Affero General Public License v3.0 only (AGPL-3.0-only). See LICENSE.

