ElementsFlow

Routing

File-tree stamps for HTTP paths, Flow names, and client units from on(http.*, flow) under src/flows.

HTTP routes are inferred from where a Flow file lives. Put http.get() in src/flows/notes/[id]/get.ts and the compiler stamps GET /notes/:id, names the Flow notes.get, and the client calls api.notes.get({ id }).

The one rule

On a tree file, omit path and name: on(http.get(), flow({ do })). The file tree stamps both. Pass either only for control — barrels, a URL that must not follow the folder, or a stable name for fx.call. Explicit always wins.

When to omit · when to pass

Omit (default)Pass explicitly
HTTP pathTree file under src/flows/<unit>/http.get(), http.post(), …Barrel index.ts; a public URL that must not match the folder; http.resource(path, ops); custom live SSE path
Flow nameSame tree file — flow({ do }) stamps unit.exportBarrel (optional — export still stamps); stable name across moves; call-only Flow you fx.call by name

Consequence: most app code looks like the create-oke template — http.get().public() + flow({ … }) — with no string path and no string name.

Smallest Example

Place the file in the tree

oke dev / oke build regenerate src/flows/generated.ts. Import it before oke() so pathless triggers receive their stamp:

src/app.ts
import "@/flows/generated";
import { oke } from "okengine";

export const app = oke({ name: "notes" });

Use pathless http.get()

The tree stamps GET /notes/:id. :id merges into in. The export name is the client method (api.notes.get):

src/flows/notes/[id]/get.ts
import { on, flow, http } from "okengine";
import { z } from "zod";

export const get = on(
  http.get({
    in: z.object({ id: z.string() }),
    out: z.object({ id: z.string() }),
  }),
  flow({
    do: async ({ id }) => ({ id }),
  }),
);

Call the endpoint

curl -X GET http://localhost:6530/notes/n_1 -H "accept: application/json"

Response:

{
  "data": { "id": "n_1" },
  "error": null
}

Method is not the filename

list.ts does not become GET by itself. Reserved leaves only omit a URL segment. Bind http.get(), http.post(), http.patch(), or http.delete() yourself — see Reserved Leaves.

Progressive Patterns

From a pathless tree file to an explicit barrel, an action leaf, and a catch-all:

One file per route. Skip the path argument. The generated barrel calls stampHttpPath / stampFlowName after import:

src/flows/notes/list.ts
import { on, flow, http } from "okengine";

export const list = on(
  http.get(),
  flow({
    do: () => [],
  }),
);

Stamped to GET /notes, Flow notes.list, client api.notes.list().

Convention Reference

ConventionFileStamped routeClient
Dynamic paramnotes/[id]/get.ts + http.get()GET /notes/:idapi.notes.get({ id })
Reserved leafnotes/list.ts + http.get()GET /notesapi.notes.list()
Action leafnotes/[id]/archive.ts + http.post()POST /notes/:id/archiveapi.notes.archive({ id })
Catch-alldocs/[...slug]/get.ts + http.get()GET /docs/*api.docs.get({ "*": "a/b" })
Route groupnotes/(ops)/archive.ts/notes/archiveapi.notes.archive
Root unitmain/health.ts + http.get()GET /healthapi.main.health()
Folder rootmain/route.ts + http.get()GET /api.main.root()

Units

The first folder under src/flows/ is the client unit. Nested folders and the leaf file build the URL. The export const name is the method on that unit.

src/flows/
├── notes/                     # Unit: notes → api.notes.*
│   ├── list.ts                # GET /notes          (reserved leaf)
│   ├── create.ts              # POST /notes         (reserved leaf)
│   ├── shapes.ts              # skipped (not a route)
│   └── [id]/
│       ├── get.ts             # GET /notes/:id
│       └── archive.ts         # POST /notes/:id/archive
├── billing/
│   └── (checkout)/            # omitted from the URL
│       └── charge.ts          # POST /billing/charge  (if http.post())
└── main/                      # Unit: main — prefix omitted from the URL
    ├── health.ts              # GET /health
    └── route.ts               # GET /

A file sitting directly in src/flows/ (no unit folder) is not a route.

Unit folder names must be valid JS identifiers (notes, main, _ allowed inside; my-notes is skipped). Folders starting with _, [, or ( are not units.

Consequence: export const getNote from get.ts is api.notes.getNote, not api.notes.get. Match the export to the name you want on the client.

Path Conventions

Default — pathless. Omit the path so generated.ts stamps the URL from disk:

http.get(); // pending until stampHttpPath runs

Control — explicit path. Pass the URL template when the folder should not own the route (barrel, public API shape, resource mount). The tree never overwrites an explicit path:

http.get("/organizations/:orgId/members/:memberId");

Path params, query string, and JSON body still merge into one object checked by in — same order as HTTP · Request Parsing.

Dynamic parameters

[id]:id. The folder name is the param key. Declare the same key on in:

src/flows/orgs/[orgId]/members/[memberId]/get.ts
import { on, flow, http } from "okengine";
import { z } from "zod";

export const get = on(
  http.get({ in: z.object({ orgId: z.string(), memberId: z.string() }) }),
  flow({
    do: async ({ orgId, memberId }) => ({ orgId, memberId }),
  }),
);

Stamped to GET /orgs/:orgId/members/:memberId.

Catch-alls

[...slug]* (greedy rest). Optional [[...slug]] is not supported — generate fails with Optional catch-all "[[...slug]]" is not supported — use [...slug] (param is "*").

A route that includes * selects the Trie matcher automatically (the compiled RegExp matcher cannot express wildcards).

Route groups

A folder (ops) is omitted from the URL. Use it to group files without adding a segment:

src/flows/notes/(ops)/archive.ts  →  /notes/archive

(ops) never enters the Flow name either (notes.archive).

The main unit

main is omitted from the URL prefix. Flow names still use main.:

FileURLFlow name
main/health.ts/healthmain.health
main/route.ts/main.root (from export const root)
main/index.ts/ (extract)barrel — pass http.get("/")

Skip list

These files are never routes (walk skips them; path inference returns nothing):

PatternWhy
generated.tsAdopt barrel (oke dev / oke build writes it)
shapes.tsShared Zod / contracts
signals.tsSignal declarations
*.test.ts / *.test.tsxTests
_ prefix (file or folder)Private helpers (notes/_lib/util.ts)

Skip-list files may sit next to tree routes. They do not turn a tree into a barrel.

Reserved Leaves

To avoid /notes/get and /orders/list, these filenames add no URL segment — the same five CRUD names as http.resource, plus folder roots:

LeafTypical triggerExample fileStamped path
listhttp.get()notes/list.ts/notes
createhttp.post()notes/create.ts/notes
gethttp.get()notes/[id]/get.ts/notes/:id
updatehttp.patch()notes/[id]/update.ts/notes/:id
removehttp.delete()notes/[id]/remove.ts/notes/:id
index(barrel only)notes/index.ts/notes
routeanynotes/route.ts or main/route.ts/notes or /

Any other leaf is a segment: query.ts/notes/query.

In a tree unit, do not add index.ts beside other route files — that is a generate error. Use route.ts (or list.ts / create.ts) for the collection root.

Barrel vs Tree

oke dev / oke build scans each src/flows/<unit>/ folder and writes generated.ts. Two shapes, never mixed:

[param] folders, (group) folders, or extra route files. The barrel imports each file and stamps path + name:

const notes = {
  get: stampHttpPath(stampFlowName(notes_$id$_get.get, "notes.get"), "/notes/:id"),
  list: stampHttpPath(stampFlowName(notes_list.list, "notes.list"), "/notes"),
};
export { notes };
registerFlowUnits({ notes });

oke() drains registerFlowUnits into $routes. .adopt({ notes }) is optional and additive.

Names

Three names, one file:

SurfaceSourceExample
HTTP pathFile tree (default) or explicit http.get("/x")/notes/:id
Flow nameunit.export from flow({ do }) (default), or flow("notes.get")notes.get
ClientUnit folder + export constapi.notes.get({ id })

Nameless flow({ do }) is the tree default — same rule as pathless HTTP. Pass flow("notes.get") only for control (stable name, barrel, or matching unit prefix). Wrong-unit prefixes fail generate.

Non-HTTP files still join the unit. A signal consumer in notes/on-created.ts is api.notes.onCreated over RPC (POST /_oke/notes/onCreated), not HTTP.

Signal / Clock consumers pass an explicit flow("…") name (OKE1072 if nameless outside src/flows/<unit>/; OKE1070 on collision). Clock may write clock.every(…) inside on()Clock · Inline or named export.

Runtime Matching

All adopted HTTP bindings go into one matcher. Wrong method on a known path is 405 with Allow. No match is a bare 404 Not Found.

oke({ router })DefaultWhat it does
"default"yesCompiled RegExp (O(1) static map + per-bucket regex for :id). Falls back to Trie when a path includes *
"edge"Linear scan, then Trie. No RegExp compile — cold-start / isolates

p99 match stays under 1 ms on the compiled matcher. You do not pick buckets by hand — a catch-all in the table selects Trie for the whole app.

Duplicate METHOD + path fails boot (OKE1041), including a resource mount plus a handwritten http.get("/notes").

Troubleshooting

Learn more

  • HTTP — verbs, envelopes, http.resource, live SSE
  • Clientapi.notes.get, REST vs RPC, $routes
  • Errors — OKE1040 · OKE1030 · OKE1041 · OKE1045 · OKE1070 · OKE1072
  • The Architecture — derived routes, no hand-written table
  • Gate.gate(...) / .public() on the same trigger

Next

On this page