Object kinds
Every top-level Xano object is a registered kind with a factory and aXano.register*
method: defineFunction, table, query, apiGroup, tool, mcpServer, agent,
task, workflowTest, middleware, addon, realtimeServer, realtimeChannel,
realtimeMessage, knowledge, microservice (its own section below), workspaceConfig,
and the seven trigger factories below. Signatures and payload keys are in
llms/object-kinds.md and llms/triggers.md; what follows is what
the types don’t tell you.
A knowledge item’s body is a file, and mode is a running cost. knowledge() is the
markdown a workspace’s AI agents read before they act, and it is the one kind whose payload
is prose — so the body is named by path (knowledgeFile("./runbook.md", import.meta.url))
rather than written as a string, and refs: knowledgeDir(...) ships a whole folder the
agent searches on demand. What the agent actually receives is decided by type and mode,
and nothing in the types warns you: an agents.md item is injected in full on every turn
whatever mode says, mode: "always" spends the body’s whole length on every request, and
the default mode: "auto" sends only the name and description until a request matches.
Write that description to be matched against a request rather than as a title. Full shape
in llms/kinds-knowledge.md.
Triggers take a callback stack. stack: (t) => [...], not the plain array every other
kind uses — because a trigger’s inputs are implied by its type (fixed by Xano, not
editable) and injected automatically. So triggers take no input field, and the typed
handle t is the only way to read them (response: (t) => ... on response-bearing types).
The seven types are tableTrigger, realtimeServerTrigger, realtimeChannelTrigger,
mcpServerTrigger, agentTrigger, workspaceTrigger, and errorTrigger; they share one
stored envelope discriminated by obj_type.
datasource is the trap. workflowTest
takes no input and no response — it calls other objects and asserts on what they bind.
Leave datasource off: the default "" runs against an empty datasource. Naming one
makes the engine clone that datasource before every run, so pointing a test at
production-sized data is slow enough to fail the run outright. "live" warns at compile
time; every other name is your call.
Empty means empty: no table({ seed }) rows exist while the test runs, so every db
read misses unless the test creates what it needs first — typically a defineFunction
fixture the stack calls before anything else. A test written against a seeded row fails
with your own precondition message, which reads as a wrong id rather than an empty
database. A failing s.api.call is the other surprise: it binds the error envelope
({code, message}) to its as and carries on rather than raising, so a later assertion
gets blamed for a call that failed several statements earlier — assert on ref("r.code")
when a call may fail. llms/tests.md carries the rest, including what s.api.call can and
cannot do about authentication.
query,
defineFunction or middleware takes a tests array — the tests the Xano editor shows.
Each is a named set of inputs run against that object, asserted with the top-level
expect.* helpers. (s.expect.* — see Authoring reference — builds a
statement for a workflow-test stack;
expect.* builds a record stored on a test. They are not interchangeable, and the types
enforce it.) Any statement in the stack can return a mock instead of doing its work,
keyed by test name — and only while that named test runs, so a mock changes nothing about
a normal request.
A unit test’s datasource is the same trap as a workflow test’s, with the same default:
"" is an empty datasource, so no table({ seed }) rows are visible while a unit
test runs either. Every db read misses, and an assertion on the first row fails against
a deployment whose endpoint returns those rows over HTTP a second later. Create what the
test needs inside the run — a defineFunction fixture the stack calls first — or mock
the read.
example. The one
thing it withholds is a test’s auth token — that is an expiring credential rather than
authored configuration, so xanots init --from reports it as a deliberate omission instead of
writing it into a committed tree.
Four of the Run … statements only run inside a workflow test. s.api.call,
s.task.call, s.trigger.call and s.workflow_test.call are resolved by the engine at
run time, and outside a workflowTest stack it cannot reach the target — so one of them in
a query, function or task type-checks, exports, imports and deploys clean, then answers
the first real request
with ERROR_FATAL: <Type> does not exist. It is not per host kind: the same call fails
identically from a function that a query runs. xanots export refuses them outside a
workflow test. s.function.call, s.tool.call, s.middleware.call and s.addon.call run
from any stack, as does s.function.run — the ordinary way to invoke a function. To share
logic between two endpoints, put it in a defineFunction and s.function.run it from both.
s.expect.to_match takes a regex PATTERN, not text. The engine runs it through PHP
preg_*, which reads the first character as the delimiter — so a c.text("^usr_.*$") there
is a pattern the engine cannot run, and the assertion fails against the very string it was
written for. c.regex("^usr_.*$") (or c.regex(/^usr_.*$/)) wraps and escapes it; a bare
c.text pattern is refused at compile time and pointed here. A ref/inp pattern, whose
text isn’t visible to the check, is passed through untouched.
Realtime — the only three-level containment chain in the SDK: realtimeServer owns
realtimeChannels, which own realtimeMessage handlers (a message is the realtime
analogue of a query — its own typed payload and stack). Pass the handle, not a name: a
channel path is unique only within its server. A channel’s input types its path params
(rooms/{room_id}); a message’s input types the message payload. A server is off
until enabled: true.
query().getPath() works — chat.getUrl(BASE)
builds the socket URL (wss://…/ws/<canonical>, with a tenant base URL translated into the
socket’s /ws/<tenant>:<canonical> form) and room.getChannel({ room_id: 42 }) builds the
path a client joins. Both throw rather than guess. In a browser bundle, reach for the
generated manifest’s socketUrl/channelPath instead — same addresses, same checks, without
importing the defs (see
The payoff: a type-safe frontend, for free).
Five traps account for most realtime bugs. The full wire protocol — every server frame,
the presence roster shape, the at-least-once client contract — is in llms/kinds-realtime.md.
- An empty return denies, and so does a crash.
connectandjoinare gates: return{ allowed: c.bool(true) }or any truthy value to admit. A stack that falls through, or a gating trigger with noresponse, refuses everyone — and a raise refuses too, because the gate is seeded with a deny it keeps when the stack throws. Both failure modes lock the door, so the risk to plan for is a self-inflicted lockout, not a breach: guard every drill inside a gate withref(path, { safe: true }), sincedb.getbindsnullon a miss.export()warns on the missingresponse; nothing can warn about the raise. Gating is opt-in — a server with noconnecttrigger admits everyone. - Only
nulldrops a message. In adelivertrigger (per recipient) and in a message handler,false/0/""all deliver the message unchanged, and a crash broadcasts the sender’s original unvalidated payload. Returnnullto suppress. So a redaction check written as a boolean sends the very message it was meant to hide. Per-viewer redaction also takes two objects: thedelivertrigger anddelivery: { perRecipient: true }on its channel. Either half alone delivers the payload unchanged to everyone, so a gate whose return semantics are perfect still ships unredacted if the flag is missing — and the flag costs a stack per recipient per message, so it is opt-in.export()warns on both halves. conversation: { enabled: true }alone stores nothing.limitdefaults to0, and0means retain none. Always pass alimit. What a handler broadcasts is the stored row, so broadcast everything a future joiner needs to render it.- An idle socket is reaped after ~10 minutes. A listen-only client (feed, dashboard,
presence sidebar) must send
{ action: "ping" }or any frame periodically, or it silently drops and reconnects forever. s.realtime.publishis the push direction, and it is fail-soft. It bypasses the channel’spublish.who(authorization belongs in your stack), does not invoke the named message’s handler, and swallows a missing or disabled server — a mis-targeted publish is silent. Pass the server handle and a filled-in path (room.getChannel({ room_id: 42 })), never the template — a constant channel still carrying{param}throws at author time, and a constant server or channel naming nothing this workspace registers warns at export.
realtimeTrigger(...) and s.api.realtime_event(...) belong to the old
workspace-global layer; they are supported only so codegen can bring back a workspace that
holds them, and they are named in llms/legacy.md rather than in the authoring catalogs.
Aiming s.api.realtime_event at a current-layer channel publishes into the void — use
s.realtime.publish({ server, channel, data }), which names the owning server and so can
resolve the channel.
MCP servers & agents — both persist under the toolset payload key, so an mcpServer
and an agent sharing a name collide. A tool({...}) is its own kind, referenced by
handle from either.
- The run result is an envelope, not the completion. The model’s text is at
.result—ref("answer")is the whole metadata object (finishReason,steps, …). Both are typed, soInferResponsereflects either. llmis a provider-discriminated union —anthropic/openai/google-genai/xano-free(which needs no API key) — each with its provider’s typed fields.- Structured output types the call site. Author
output: { schema: { … } }on the agent with theinput.*catalog and.resultis typed from it wherever the handle is passed — no second witness. The type-onlyresultShapeis only for overriding that, or for an agent referenced by bare name. - String settings are Twig-templated at run time. The
argsyou pass tos.ai.agent.runbecome{{ $args }}(env vars are{{ $env.NAME }}), which is how an endpoint’s inputs reach the prompt. Numeric and boolean fields are not templated. Build a dynamic arg withobj({...}), notc.obj. mcpServer().getUrl(HOST)derives the Streamable-HTTP endpoint from the def, the same contract asquery.getPath(). Resolve once — handing the result back in as aHOSTthrows rather than append a second endpoint path. Agents expose onlygetCanonical().
s.function.run and s.ai.agent.run take a runtime block
({ mode: "async-shared" }, or "async-dedicated" with cpu/memory/timeout/maxRetry)
that moves the call off the request path. This is not a performance knob: Xano rewrites
an async call to a statement that dispatches and continues, so it does not return the
function’s result — don’t bind as expecting a value. Collect results later with
s.await({ ids }).
Microservices
A microservice is a container workload deployed alongside the workspace and called from a stack withs.microservice.request. Two mutually exclusive shapes chosen by kind: builtin
declares containers (image/ports/resources/env/command/args) plus optional ingresses, and
helm points at a chart and its values; passing both throws.
port folds into the single "name:port" host string
the engine reads, and is optional — a microservice exposing exactly one servicePort
resolves to it, and one exposing several requires it. A port the microservice doesn’t expose
is a type error where the def’s ports are known, and a build-time throw otherwise:
host and path are required. method, params, headers, timeout, and
follow_location default to the engine’s own values (GET, {}, [], 10, true) and are
always written — this statement’s schema requires them, so they can’t be left off the wire;
you just don’t have to type them.
host binds by name, not by guid, because that is how the engine resolves it — so renaming
a microservice fixes every call site at once. A plain "name:port" string is also accepted
and is the only way to reach an instance-level microservice, which isn’t a workspace object;
nothing checks that spelling, so prefer the def wherever there is one.
A container takes time to come up, so xanots deploy waits for it: after the import it
reads each microservice and reports whether it is ready, still starting, or failed, then
lists them. Skip the wait with --skip-liveness. The same report is available any time from
xanots status, xanots ephemeral get <env>, and xanots workspace details.
Two outcomes, and only one of them is a warning:
- The engine reports the microservice broken (an image that won’t pull, a container that won’t start) — the deploy exits 4. Waiting longer cannot change that answer, and a URL and a ✓ printed over a dead workload is not a successful deploy. This is the default; there is no flag to turn it off.
- It simply hasn’t reported ready by the end of the wait — a warning, exit
0. The backend is live and a slow container usually follows moments later. Pass--require-microservicesto make that exit 4 too, which is what CI wants: nobody is there to find out whether “should come up shortly” happened.
--static upload that failed while the
backend deploy stood): the import committed, and something it deployed is not serving. The
URL and the JSON summary still print either way — the exit code is what carries the
difference.
tenantDeploy: "manual" rows are reported but never waited on — nothing starts them for you.
Reach for it when the row should exist without a workload behind it; examples/sandbox uses
it so deploying the examples doesn’t wait on containers.
Container names are free-form: they need not match the microservice’s own name, and nothing
about addressing depends on them. A stack reaches the microservice name (plus a
servicePort), whichever containers sit behind it, so a multi-container workload names each
one for what it is.
This surface is early and expected to change, and every export of a workspace declaring a
microservice prints a notice saying so — the docs are read before writing, which is not where
you are when it matters. configs and volumes are typed and @deprecated but not
deployable: the engine rejects an import carrying either, so export() fails the build
rather than letting the deploy fatal minutes in, after provisioning has begun. Declare a value
the workload reads as a container env entry, and storage as a container volumes entry
(emptyDir, persistent, or config). Both fields stay typed so a pulled workspace holding
one still decodes.
Two fields carry secrets into the bundle — and into a pulled tree — verbatim:
chart.values and registryAuth.dockerconfigjson, because otherwise a pulled microservice
could not be redeployed.
What “out of band” can and cannot mean here. Both are stored strings the engine keeps
exactly as given, with no deploy-time indirection — no env() form, no template the tenant
resolves. So the spelling that looks safe is the wrong one:
- Leave
registryAuthunset — a public image, or a pull credential attached to the microservice outside this workspace. Nothing then carries a credential. - Accept that the tree is secret-bearing — keep
workspace.jsonand any pulled tree out of git, or rotate the credential once it lands there.
--strict does not promote it, since shipping a
private-registry workload is a legitimate end state. When what you actually need is a secret
your stack reads, the mapped surface is workspaceConfig({ env }) + env("NAME") — see
Middleware, request history & env vars.