Your first outcome in 5 minutes
You have an HTTP service (Express, Fastify, Koa, Hono, node:http or Next.js). By the end of this page, one thing it promises its callers is protected: if a change breaks it, Oodle blocks the merge.
You need Node 20.11 or newer. Nothing else here needs the concepts. Those can wait until day two.
1. Install and init (1 minute)
npm i -D @oodlc/oodle
npx oodle init
On pnpm, yarn or bun, install with that tool (pnpm add -D @oodlc/oodle, then pnpm exec oodle init).
init reads your service without changing it:
✔ Wrapped your service express app in src/app.ts, run through oodle.app.ts
Outbound calls, named under effects in oodle.app.ts and stubbed in oodlc/config.yaml:
→ api.sendgrid.com as sendgrid.request · src/payments.ts
→ api.stripe.com as stripe.request · package.json (stripe), src/payments.ts
A first catalog, from probing each route:
+ outcome post-orders
+ outcome post-orders-id-refund
- It finds the app. When
src/server.tsonly callsapp.listen(),initfollows the import to the module that builds the app. - It names the outbound calls. It looks for URLs in your code and SDKs like
stripeinpackage.json, lists each host undereffectsinoodle.app.ts, and gives each one a placeholder stub inoodlc/config.yaml. Oodle runs your app in a sealed simulation, so these stubs answer instead of the real APIs. - It finds your database. If the service uses Postgres (
pg,postgres, Prisma's pg adapter),initaddsdatabase:tooodlc/config.yaml, pointed at your migrations. Oodle runs a real Postgres in process for every run, so install it once:npm i -D @electric-sql/pglite. See A real database. - It writes a first catalog. It sends a request to each route in the simulation and saves what came back as a proposed outcome in
oodlc/proposed.yaml. A proposal runs and is reported, but blocks nothing until you approve it.
2. Check the wiring (1 minute)
npx oodle doctor
Each problem comes with its fix. The usual ones on day one, from init or doctor:
| Oodle says | do this |
|---|---|
src/server.ts calls listen() on import. Guard it |
Only listen when run directly: if (import.meta.main) app.listen(port), or if (require.main === module) in CommonJS. Then npx oodle propose --routes writes the proposals init couldn't. |
no stub for stripe.request, named in oodle.app.ts |
Add it under defaults.given.stubs in oodlc/config.yaml. |
reaches the real network: api.example.com:443 |
Add the host under effects in oodle.app.ts, then stub that effect. |
reaches the real network: localhost:5432 |
That's Postgres: add database: { schema: db/migrations } (your migrations folder) to oodlc/config.yaml. |
given.db.ordrs: no such table; did you mean orders? |
Fix the table name in given.db. Each run starts from the rows it lists. |
When it ends with ✔ Ready, Oodle is running your code.
3. Make one proposal a promise (2 minutes)
Open oodlc/proposed.yaml. Each entry is what a route did when Oodle called it:
- id: post-orders
intent: service-available
statement: "TODO: say what a caller can count on. Observed: POST /orders answered 400"
boundary: external
trigger:
http: POST /orders
given:
body: {}
expect:
status: 400
body:
error: empty_cart
status: proposed
Pick the one that would hurt most to break. Say what it promises, in words a customer would recognise, and delete the status: proposed line:
- id: orders.empty-cart-rejected
intent: service-available
statement: An order with nothing in it is refused with a clear error, and nobody is charged
boundary: customer
trigger:
http: POST /orders
given:
body: {}
expect:
status: 400
body:
error: empty_cart
Move it into oodlc/outcomes.yaml if you like. Any .yaml file in oodlc/ works. Delete the proposals you don't want.
4. Run it (30 seconds)
npx oodle run
Outcomes declared · blocking
✔ service.reachable external 2ms
✔ orders.empty-cart-rejected customer 4ms
service.reachable is the starter outcome for GET /health, written by init because your service has one.
Now break it on purpose: change empty_cart to cart_empty in the code and run again. Oodle names what broke and exits with 1. Change it back.
When a run fails, the first line under the outcome is the cause. A call with no stub reads no stub for external call "stripe.request", not just status: expected 201, got 500.
5. Put it in CI (30 seconds)
npx oodle init --ci
In a project that already has oodlc/, this writes only .github/workflows/oodle.yml. Commit it with your outcome. From now on, every pull request gets an outcome diff, and one that breaks orders.empty-cart-rejected can't merge.
Day two
Once one outcome holds, add the rest as you need them:
- Data to start from. With a database, list the rows each outcome needs under
given.db, and expect the writes that matter, e.g.{ kind: db.orders.inserted, count: 1 }. - Real stub answers. Replace each
{ result: {} }with what the API actually returns, so routes that call Stripe or SendGrid get past the call. - More outcomes.
npx oodle propose --routesproposes one for each route nothing describes yet.npx oodle draft brief.mdwrites a prompt that turns a PRD or ticket into proposals. - Conditions: the same promise under a slow provider, a returning customer, or no credentials.
- Constraints: invariants that must hold on every run, like "never charge without an order".
- The security pack: built-in conditions for missing credentials, injection, oversize bodies, mass assignment and replays.
- Approvals: how a reviewer approves an intended change to a promise on a pull request.
All of it is in the README.