Skip to main content

Object kinds

Every top-level Xano object is a registered kind with a factory and a Xano.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.
A workflow test is an end-to-end test, and its 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.
Saved unit tests are a different thing, and they hang off the object. A 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.
A pull brings tests back, along with a query’s saved request/response 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.
The client side is derived too, the same way 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. connect and join are gates: return { allowed: c.bool(true) } or any truthy value to admit. A stack that falls through, or a gating trigger with no response, 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 with ref(path, { safe: true }), since db.get binds null on a miss. export() warns on the missing response; nothing can warn about the raise. Gating is opt-in — a server with no connect trigger admits everyone.
  • Only null drops a message. In a deliver trigger (per recipient) and in a message handler, false/0/"" all deliver the message unchanged, and a crash broadcasts the sender’s original unvalidated payload. Return null to 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: the deliver trigger and delivery: { 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. limit defaults to 0, and 0 means retain none. Always pass a limit. 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.publish is the push direction, and it is fail-soft. It bypasses the channel’s publish.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.
The superseded realtime layer. Xano has had two realtime generations and they reuse the same words. 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 .resultref("answer") is the whole metadata object (finishReason, steps, …). Both are typed, so InferResponse reflects either.
  • llm is a provider-discriminated unionanthropic / 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 the input.* catalog and .result is typed from it wherever the handle is passed — no second witness. The type-only resultShape is only for overriding that, or for an agent referenced by bare name.
  • String settings are Twig-templated at run time. The args you pass to s.ai.agent.run become {{ $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 with obj({...}), not c.obj.
  • mcpServer().getUrl(HOST) derives the Streamable-HTTP endpoint from the def, the same contract as query.getPath(). Resolve once — handing the result back in as a HOST throws rather than append a second endpoint path. Agents expose only getCanonical().
Background execution. 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 with s.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.
Call it by passing the def itself. 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:
Only 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-microservices to make that exit 4 too, which is what CI wants: nobody is there to find out whether “should come up shortly” happened.
Exit 4 is the microservice sibling of exit 3 (a --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:
Two honest options, both about where the bytes live rather than about hiding them:
  1. Leave registryAuth unset — a public image, or a pull credential attached to the microservice outside this workspace. Nothing then carries a credential.
  2. Accept that the tree is secret-bearing — keep workspace.json and any pulled tree out of git, or rotate the credential once it lands there.
Export prints a notice naming every microservice whose bundle bytes carry either field, so this can’t happen quietly; --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.