Endpoint names, path params, and encoding
-
URL path params → name the endpoint with
{param}segments and declare an input per segment.getPath({ params })fills them, with the keys typed from the name itself:Every{param}must have a matching input orquery()throws — Xano treats an unbound marker as inert route text, so the endpoint would answer on the path and see nothing. Inputs that aren’t in the path (verbose) stay ordinary query-string params. Never interpolate the path by hand:getPathpercent-encodes each value, so a?,#or space stays inside its segment instead of restructuring the URL, and it throws on the two values encoding can’t contain — one holding/, and one that is.or..(a URL parser drops those before routing, addressing a different endpoint).realtimeChannel()paths work identically, minus the encoding: a channel address is matched literally, not parsed as a URL. -
InferRow<typeof post>→ the table’s row type. Rename or retype a column and every consumer breaks at compile time — exactly where you want it. -
InferResponse<typeof someQuery>→ the endpoint’s response type, closing the round trip. It auto-derives the common shapes with no codegen: an object-literal response yields those keys, and a query that returns a variable filled by a db op derives that op’s result — the full row fordb.add/db.edit/db.patch/db.add_or_edit(→Row— each binds the full written row rather than null, so it stays non-nullable; a genuine miss throws instead of yielding null —NotFound/404 foredit/patch, a unique-constraint error foradd, whileadd_or_editupserts and never misses),Row | nullfordb.get(it bindsnullon a miss rather than throwing — handle the not-found path), a row list for
Inferring the response type
db.query/db.bulk.patch (→ Row[]), a boolean for db.has, a number count for
db.bulk.delete, and a get/query output: [...] selection narrows to a Pick (still
| null for get). A dotted entry selects sub-keys of an object column
(output: ["id", "meta.url"], on a statement or an addon); the narrowing keys off the
path’s root, since an object column’s sub-keys aren’t declared in the schema.
Where the shape isn’t statically knowable — a value reshaped by a filter/lambda, built by
control flow, or from an op the engine itself leaves untyped (db.del, db.bulk.add/bulk.update,
raw direct_query) — it resolves to unknown; declare responseShape to close it.
In an object-literal response the keys are always known, but a value is typed only
when it traces to a binding: response: { success: c.bool(true) } derives
{ success: unknown }, not { success: boolean }. A constant carries no reference to
follow. Reach for responseShape when a client needs those keys typed.
A nested member resolves by those same rules, to any depth, in either spelling —
response: { user: obj({ id: ref("row.id") }) } and the raw literal
response: { user: { id: ref("row.id") } } both derive { user: { id: number | null } }.
A call carries the shape across: s.function.call/s.function.run, s.tool.call and
s.api.call (workflow-test only, above) given a def handle bind their as with the
target’s own InferResponse,
so ref("discount_result.discount_cents") in the caller types to that field instead of
unknown. What the target resolves to is what propagates, so declaring responseShape on
the target fixes every caller at once. A target named by string has no def to read and
stays unknown, as does an async s.function.run (it binds a job handle, not the result).
The same derivation runs on every response-bearing kind — query(), defineFunction(),
realtimeMessage(), tool(), middleware(), and the response-bearing triggers — and each
of them takes responseShape. A trigger builds its stack and response through callbacks
(stack: (t) => [...]), and the trace follows through them. This matters most where a
handler’s response is the only type a client has: a realtime message’s broadcast payload,
or a channel deliver trigger’s return, which is that recipient’s copy of the message.
response: { success: c.bool(true), id: inp("id") } — not c.obj({ ... }). c.obj builds a
constant, so a tagged value nested inside it would serialize as internal representation the
engine can’t decode (a runtime 500); nesting one is now a compile error that points you at the
record form (issue #42). A nested plain object in a record
response (response: { user: { id: ref("u"), age: 3 } }) is auto-wrapped for you — no manual
obj({ ... }) — and raw literals in a call/agent input map coerce too
(s.function.run({ fn, input: { max_age_days: 3 } }) — no c.int(3)).
When a response is filtered, computed, or otherwise opaque to the static walk, declare it once
on the query and every caller derives from that single source of truth:
response: ref("row") off a s.db.get needs no responseShape — it already
derives InferRow<typeof post> | null, since db.get misses to null.)
Factoring statements into a helper. The trace walks the stack’s tuple, so spreading a
helper typed Statement[] widens the stack and nothing in it resolves any more — the response
types as StackTupleWidened, which names the cause. Return statements(...) from the helper
and the tuple survives the spread:
responseShape there.
This mirrors how the Xano engine itself derives an endpoint’s response schema (a static walk of
the stack), so what you get in the type is what the endpoint actually returns — and it degrades
to unknown in exactly the cases the engine can’t resolve either.
A GET endpoint carries its inputs in the query string rather than a JSON body:
@xanots/sdk entry has zero Node dependencies, so importing your workspace
graph into a browser bundle just works. The node:fs-backed emitters live in the
separate @xanots/sdk/node entry a frontend never pulls in.
Bundle size, and the route manifest
Bundle size & tree-shaking.@xanots/sdk is sideEffects: false, so a bundler drops
the SDK exports your frontend doesn’t use. But importing a query def for its getPath()
also pulls whatever its stack builds — the s.*/c.* factory calls run at module load
to construct the def, so they can’t be tree-shaken out. Types are free (InferInput/
InferRow erase to nothing — use import type). That cost is a floor, not a function of
how lean the def is. Measured on a Vite lib build against the published package: one
apiGroup + one empty query, imported for a single getPath(), is 267 kB minified
(65 kB gzipped) against 56 B for a hand-written path string. A realistic def — two
tables, a foreign key, a typed input, a db.query with a where and a sort, plus a
second endpoint — measures 269 kB. That 2 kB spread is the point: the floor is the SDK
runtime itself, so splitting modules or simplifying a def does not move it, and the cost
is paid by importing any def at all.
Generate a route manifest instead. It keeps the derived-not-hardcoded contract at
almost no bundle cost:
{param} names are still checked at compile time, so a backend rename is a compile error
rather than a 404:
"<VERB> <name>", which is the endpoint identity the engine itself uses —
so verb-differentiated siblings are ordinary, and a REST-shaped group emits without
renaming anything:
POST sibling never renames the GET
that was already there. Two api groups holding the same verb AND name is the one shape a
single manifest cannot express, and the emit fails naming both rather than picking one.
Realtime is in the same file when the workspace has any: socketUrl(server, baseUrl) for the
websocket URL and channelPath(channel, params) for the path a frame’s channel field takes,
both keyed and {param}-checked exactly like the routes. socketUrl is the equivalent of
realtimeServer().getUrl() down to the tenant rule — a base URL that names a tenant
(https://host/tenant/ab-cd, what deploy injects as window.XANO_HOST) is rewritten to the
socket’s own wss://host/ws/ab-cd:<canonical> form, which is the one address a frontend has no
way to reconstruct. Resolve once, from the https:// base — feeding a resolved socket URL back
in as a baseUrl throws rather than append a second /ws/…:
--strict in CI to fail when the committed manifest is out of date. A hand-typed
ROUTES table is the option that gives up both the bundle saving and the rename safety.