@xanots/sdk 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +38 -1
  3. package/dist/.build-fingerprint +1 -1
  4. package/dist/agent-file-refresh-IJ7GN2FJ.js +17 -0
  5. package/dist/bin.js +12 -153
  6. package/dist/{capture-HUV5BNTC.js → capture-7PO6SGB4.js} +2 -2
  7. package/dist/{chunk-CMZLPTGW.js → chunk-7SISR3CS.js} +2 -2
  8. package/dist/{chunk-XQ22GLYS.js → chunk-AAFBSOSD.js} +6 -6
  9. package/dist/{chunk-BT2CSEC5.js → chunk-APOR6TCV.js} +4 -4
  10. package/dist/{chunk-55VHDNW5.js → chunk-BPEEGJJA.js} +108 -3
  11. package/dist/{chunk-U3G2UW65.js → chunk-CYJ7R3AD.js} +3 -3
  12. package/dist/{agent-file-refresh-6M5M7T24.js → chunk-D6OGU2AI.js} +5 -6
  13. package/dist/chunk-ERQZFWIW.js +22 -0
  14. package/dist/{chunk-DYCLVQXW.js → chunk-F5T45NAM.js} +31 -2
  15. package/dist/{chunk-TQUO2OXY.js → chunk-FN4OQT2J.js} +4 -4
  16. package/dist/{chunk-6USV65XA.js → chunk-IVP6TS26.js} +2 -2
  17. package/dist/{chunk-HQ2CBRVI.js → chunk-KXCPZUUM.js} +10 -8
  18. package/dist/{chunk-7REDODS2.js → chunk-LLWK6H77.js} +20 -2
  19. package/dist/{chunk-HJPTWBLH.js → chunk-MFIHIS6B.js} +2 -2
  20. package/dist/{chunk-3NFUWXOC.js → chunk-MS3BRZAZ.js} +146 -45
  21. package/dist/{chunk-2V4YE6QC.js → chunk-OARSPAIE.js} +112 -21
  22. package/dist/{chunk-7DKX2SPN.js → chunk-OR43PDCW.js} +4 -4
  23. package/dist/chunk-PGBK64U5.js +59 -0
  24. package/dist/{chunk-F6JCFRKO.js → chunk-PXXLBXOP.js} +2 -2
  25. package/dist/{chunk-W2G2WPTB.js → chunk-QA5ICJ4M.js} +3 -3
  26. package/dist/{chunk-YZ4GU6F5.js → chunk-QUUB7HYK.js} +5 -838
  27. package/dist/{chunk-C56BC2FY.js → chunk-SG4UBJ47.js} +2 -2
  28. package/dist/{chunk-76QBEIGO.js → chunk-T4XPCJRF.js} +3 -2
  29. package/dist/{chunk-VTIL47DT.js → chunk-W5NOKYEG.js} +6 -2
  30. package/dist/chunk-WGDAOOXG.js +845 -0
  31. package/dist/{chunk-DGSF2Q5H.js → chunk-WJYB7DSI.js} +2 -2
  32. package/dist/{chunk-4YMD2OOZ.js → chunk-XHEXOES3.js} +1 -1
  33. package/dist/{chunk-4HT3BNZ7.js → chunk-YUBJLB6G.js} +10 -2
  34. package/dist/{chunk-5L4X5LS6.js → chunk-YUPQOLFX.js} +77 -2
  35. package/dist/chunk-ZGA5MUNC.js +173 -0
  36. package/dist/{chunk-P3TTMWUP.js → chunk-ZOYMZZ3S.js} +8 -1
  37. package/dist/chunk-ZSYZTGJH.js +81 -0
  38. package/dist/cli.d.ts +14 -1
  39. package/dist/cli.js +8 -8
  40. package/dist/codegen-command-BLOS3GR3.js +43 -0
  41. package/dist/{completion-BKAFCZBE.js → completion-GHP5RRLQ.js} +2 -2
  42. package/dist/{deploy-command-4R6BYC6G.js → deploy-command-XOH76USO.js} +27 -27
  43. package/dist/{env-target-XWS2ZZ2Y.js → env-target-POGJMD6Q.js} +7 -7
  44. package/dist/{ephemeral-command-JL4TPIRQ.js → ephemeral-command-46SL27GJ.js} +25 -25
  45. package/dist/index.d.ts +2 -2
  46. package/dist/index.js +13 -7
  47. package/dist/init-command-NCRPVFGE.js +30 -0
  48. package/dist/internal.d.ts +2 -2
  49. package/dist/internal.js +50 -5
  50. package/dist/{io-AMIKRLPC.js → io-7VIA5SON.js} +3 -3
  51. package/dist/{live-diff-RXSJCVJ7.js → live-diff-FP4SFNT4.js} +2 -2
  52. package/dist/{lock-3CVKALKT.js → lock-KXOJIGCG.js} +2 -2
  53. package/dist/{lock-commands-XOYS75YQ.js → lock-commands-6UONZW26.js} +9 -9
  54. package/dist/{login-command-ACJF6KWQ.js → login-command-SG7IWTHW.js} +140 -37
  55. package/dist/{logout-command-MX3MJS5U.js → logout-command-J2AG5NKC.js} +3 -3
  56. package/dist/{loop-SAWAOUFO.js → loop-7SAIGRCZ.js} +3 -3
  57. package/dist/{marketplace-command-UVV3XAOL.js → marketplace-command-NKTQ3VPS.js} +11 -25
  58. package/dist/meta-client-OW5WKWW7.js +1 -1
  59. package/dist/node.d.ts +2 -2
  60. package/dist/node.js +18 -12
  61. package/dist/onboard-command-EHOHKQQU.js +36 -0
  62. package/dist/{profile-command-SWJ3SPKR.js → profile-command-ZPC2DPFV.js} +6 -6
  63. package/dist/{release-command-HZUY2XZX.js → release-command-FKQ6E3TD.js} +25 -25
  64. package/dist/{sandbox-details-command-HJE5SPVG.js → sandbox-details-command-DMY2KGA2.js} +5 -5
  65. package/dist/{sandbox-export-command-QCJY4GMV.js → sandbox-export-command-R6QMEK3D.js} +8 -8
  66. package/dist/scaffold.js +4 -2
  67. package/dist/{store-g45zwB33.d.ts → store-BJONDJoZ.d.ts} +171 -2
  68. package/dist/{test-command-72Y5S22H.js → test-command-56IAEYHX.js} +11 -11
  69. package/dist/upgrade-command-M2DZ3ZPD.js +177 -0
  70. package/dist/{validate-command-KDGH537H.js → validate-command-CIBQJND7.js} +12 -12
  71. package/dist/{workspace-command-ICSI6PKO.js → workspace-command-HZS43PHJ.js} +28 -27
  72. package/dist/workspace-export-AJMGN3CQ.js +1 -1
  73. package/guides/README.md +30 -0
  74. package/guides/authoring.md +642 -0
  75. package/guides/cli.md +192 -0
  76. package/guides/codegen.md +83 -0
  77. package/guides/coverage.md +67 -0
  78. package/guides/deploying.md +358 -0
  79. package/guides/environment.md +132 -0
  80. package/guides/object-kinds.md +376 -0
  81. package/guides/project-structure.md +43 -0
  82. package/guides/scaffold.md +198 -0
  83. package/guides/typed-frontend.md +201 -0
  84. package/llms/kinds-knowledge.md +20 -0
  85. package/llms/object-kinds.md +1 -0
  86. package/llms-full.txt +25 -2
  87. package/llms.txt +3 -2
  88. package/manifest.json +31 -4
  89. package/package.json +5 -2
  90. package/dist/chunk-WUSKBXXD.js +0 -25
  91. package/dist/chunk-ZO3HJOCJ.js +0 -29
  92. package/dist/codegen-command-FIADGXRN.js +0 -42
  93. package/dist/init-command-LOK75W64.js +0 -29
@@ -0,0 +1,376 @@
1
+ # Object kinds
2
+
3
+ Every object kind XanoTS can author, and how to split a workspace across microservices.
4
+
5
+ ## Object kinds
6
+
7
+ Every top-level Xano object is a registered kind with a factory and a `Xano.register*`
8
+ method: `defineFunction`, `table`, `query`, `apiGroup`, `tool`, `mcpServer`, `agent`,
9
+ `task`, `workflowTest`, `middleware`, `addon`, `realtimeServer`, `realtimeChannel`,
10
+ `realtimeMessage`, `knowledge`, `microservice` (its own section below), `workspaceConfig`,
11
+ and the seven trigger factories below. Signatures and payload keys are in
12
+ `llms/object-kinds.md` and `llms/triggers.md`; what follows is what
13
+ the types don't tell you.
14
+
15
+ **A knowledge item's body is a file, and `mode` is a running cost.** `knowledge()` is the
16
+ markdown a workspace's AI agents read before they act, and it is the one kind whose payload
17
+ is prose — so the body is named by path (`knowledgeFile("./runbook.md", import.meta.url)`)
18
+ rather than written as a string, and `refs: knowledgeDir(...)` ships a whole folder the
19
+ agent searches on demand. What the agent actually receives is decided by `type` and `mode`,
20
+ and nothing in the types warns you: an `agents.md` item is injected in full on every turn
21
+ whatever `mode` says, `mode: "always"` spends the body's whole length on every request, and
22
+ the default `mode: "auto"` sends only the name and `description` until a request matches.
23
+ Write that `description` to be matched against a request rather than as a title. Full shape
24
+ in `llms/kinds-knowledge.md`.
25
+
26
+ **Triggers take a callback stack.** `stack: (t) => [...]`, not the plain array every other
27
+ kind uses — because a trigger's inputs are **implied by its type** (fixed by Xano, not
28
+ editable) and injected automatically. So triggers take no `input` field, and the typed
29
+ handle `t` is the only way to read them (`response: (t) => ...` on response-bearing types).
30
+ The seven types are `tableTrigger`, `realtimeServerTrigger`, `realtimeChannelTrigger`,
31
+ `mcpServerTrigger`, `agentTrigger`, `workspaceTrigger`, and `errorTrigger`; they share one
32
+ stored envelope discriminated by `obj_type`.
33
+
34
+ ```ts
35
+ tableTrigger({
36
+ name: "on-user-insert",
37
+ table: users,
38
+ actions: { insert: true },
39
+ // Optional row filter, evaluated by the DATABASE before the stack runs — so it
40
+ // names the SQL pseudo-tables with col(), NOT the t handle. Rejected with
41
+ // `truncate`; insert cannot read OLD.*, delete cannot read NEW.*.
42
+ search: cmp(col("NEW.email"), "!=", c.text("")),
43
+ stack: (t) => [
44
+ // t.new("email") is typed to the row; t.action is the op; t.old is null (insert-only).
45
+ s.db.add({ table: auditLog, row: { email: t.new("email"), event: t.action } }),
46
+ ],
47
+ });
48
+ ```
49
+
50
+ **A workflow test is an end-to-end test, and its `datasource` is the trap.** `workflowTest`
51
+ takes no `input` and no `response` — it calls other objects and asserts on what they bind.
52
+ Leave `datasource` off: the default `""` runs against an **empty** datasource. Naming one
53
+ makes the engine **clone** that datasource before every run, so pointing a test at
54
+ production-sized data is slow enough to fail the run outright. `"live"` warns at compile
55
+ time; every other name is your call.
56
+
57
+ Empty means empty: **no `table({ seed })` rows exist while the test runs**, so every `db`
58
+ read misses unless the test creates what it needs first — typically a `defineFunction`
59
+ fixture the stack calls before anything else. A test written against a seeded row fails
60
+ with your own precondition message, which reads as a wrong id rather than an empty
61
+ database. A failing `s.api.call` is the other surprise: it **binds the error envelope**
62
+ (`{code, message}`) to its `as` and carries on rather than raising, so a later assertion
63
+ gets blamed for a call that failed several statements earlier — assert on `ref("r.code")`
64
+ when a call may fail. `llms/tests.md` carries the rest, including what `s.api.call` can and
65
+ cannot do about authentication.
66
+
67
+ ```ts
68
+ workflowTest({
69
+ name: "signup_works",
70
+ tags: ["smoke"],
71
+ // datasource omitted on purpose — "" is an EMPTY datasource, not "no datasource".
72
+ stack: [
73
+ s.function.call({ fn: createUser, input: { email: "a@b.c" }, as: "created" }),
74
+ s.expect.to_be_defined({ expr: ref("created") }),
75
+ s.expect.to_equal({ expr: ref("created.status"), value: c.text("ok") }),
76
+ // A regex assertion takes a PATTERN, so build it with `c.regex(...)`.
77
+ s.expect.to_match({ expr: ref("created.id"), value: c.regex("^usr_[a-z0-9]+$") }),
78
+ ],
79
+ });
80
+ ```
81
+
82
+ **Saved unit tests are a different thing, and they hang off the object.** A `query`,
83
+ `defineFunction` or `middleware` takes a `tests` array — the tests the Xano editor shows.
84
+ Each is a named set of inputs run against *that* object, asserted with the top-level
85
+ `expect.*` helpers. (`s.expect.*` — see [Authoring reference](authoring.md) — builds a
86
+ *statement* for a workflow-test stack;
87
+ `expect.*` builds a record stored on a test. They are not interchangeable, and the types
88
+ enforce it.) Any statement in the stack can return a **mock** instead of doing its work,
89
+ keyed by test name — and only while that named test runs, so a mock changes nothing about
90
+ a normal request.
91
+
92
+ A unit test's `datasource` is the same trap as a workflow test's, with the same default:
93
+ `""` is an **empty** datasource, so **no `table({ seed })` rows are visible while a unit
94
+ test runs** either. Every `db` read misses, and an assertion on the first row fails against
95
+ a deployment whose endpoint returns those rows over HTTP a second later. Create what the
96
+ test needs inside the run — a `defineFunction` fixture the stack calls first — or `mock`
97
+ the read.
98
+
99
+ ```ts
100
+ query({
101
+ name: "score",
102
+ verb: "POST",
103
+ input: { score: input.int({ required: true }) },
104
+ tests: [
105
+ {
106
+ name: "adds one",
107
+ input: { score: c.int(1) },
108
+ // Subject first — argument order is the assertion.
109
+ expect: [expect.to_equal(resp(), c.int(2))],
110
+ },
111
+ ],
112
+ stack: [
113
+ // Returns 2 while "adds one" runs; does nothing on a real request. A name
114
+ // no test declares throws at compile time.
115
+ s.set_var("total", c.expression("$input.score + 1"), {
116
+ mock: { "adds one": c.int(2) },
117
+ }),
118
+ ],
119
+ response: ref("total"),
120
+ });
121
+ ```
122
+
123
+ A pull brings tests back, along with a query's saved request/response `example`. The one
124
+ thing it withholds is a test's auth `token` — that is an expiring credential rather than
125
+ authored configuration, so `xanots codegen` reports it as a deliberate omission instead of
126
+ writing it into a committed tree.
127
+
128
+ **Four of the `Run …` statements only run inside a workflow test.** `s.api.call`,
129
+ `s.task.call`, `s.trigger.call` and `s.workflow_test.call` are resolved by the engine at
130
+ run time, and outside a `workflowTest` stack it cannot reach the target — so one of them in
131
+ a query, function or task type-checks, exports, imports and **deploys clean**, then answers
132
+ the first real request
133
+ with `ERROR_FATAL: <Type> does not exist`. It is not per host kind: the same call fails
134
+ identically from a function that a query runs. `xanots export` refuses them outside a
135
+ workflow test. `s.function.call`, `s.tool.call`, `s.middleware.call` and `s.addon.call` run
136
+ from any stack, as does `s.function.run` — the ordinary way to invoke a function. To share
137
+ logic between two endpoints, put it in a `defineFunction` and `s.function.run` it from both.
138
+
139
+ **`s.expect.to_match` takes a regex PATTERN, not text.** The engine runs it through PHP
140
+ `preg_*`, which reads the first character as the delimiter — so a `c.text("^usr_.*$")` there
141
+ is a pattern the engine cannot run, and the assertion fails against the very string it was
142
+ written for. `c.regex("^usr_.*$")` (or `c.regex(/^usr_.*$/)`) wraps and escapes it; a bare
143
+ `c.text` pattern is refused at compile time and pointed here. A `ref`/`inp` pattern, whose
144
+ text isn't visible to the check, is passed through untouched.
145
+
146
+ **Realtime** — the only three-level containment chain in the SDK: `realtimeServer` owns
147
+ `realtimeChannel`s, which own `realtimeMessage` handlers (a message is the realtime
148
+ analogue of a query — its own typed payload and stack). Pass the **handle**, not a name: a
149
+ channel path is unique only within its server. A channel's `input` types its **path** params
150
+ (`rooms/{room_id}`); a message's `input` types the message **payload**. A server is off
151
+ until `enabled: true`.
152
+
153
+ ```ts
154
+ const chat = realtimeServer({ name: "chat", enabled: true });
155
+
156
+ const room = realtimeChannel({
157
+ name: "rooms/{room_id}", // `input` types the PATH params
158
+ server: chat,
159
+ input: { room_id: input.int() },
160
+ publish: { who: "authenticated" },
161
+ conversation: { enabled: true, limit: 50 }, // client-visible transcript
162
+ });
163
+
164
+ realtimeMessage({
165
+ name: "send", // `input` types the message PAYLOAD
166
+ channel: room, // the handle carries the server too
167
+ input: { body: input.text({ required: true }) },
168
+ deliverTo: "channel", // or "sender" (request/response) / "others"
169
+ stack: [s.debug.log({ value: inp("body") })],
170
+ });
171
+ ```
172
+
173
+ The client side is derived too, the same way `query().getPath()` works — `chat.getUrl(BASE)`
174
+ builds the socket URL (`wss://…/ws/<canonical>`, with a tenant base URL translated into the
175
+ socket's `/ws/<tenant>:<canonical>` form) and `room.getChannel({ room_id: 42 })` builds the
176
+ path a client joins. Both throw rather than guess. In a **browser bundle**, reach for the
177
+ generated manifest's `socketUrl`/`channelPath` instead — same addresses, same checks, without
178
+ importing the defs (see
179
+ [The payoff: a type-safe frontend, for free](#the-payoff-a-type-safe-frontend-for-free)).
180
+
181
+ Five traps account for most realtime bugs. The full wire protocol — every server frame,
182
+ the presence roster shape, the at-least-once client contract — is in `llms/kinds-realtime.md`.
183
+
184
+ - **An empty return denies, and so does a crash.** `connect` and `join` are gates: return
185
+ `{ allowed: c.bool(true) }` or any truthy value to admit. A stack that falls through, or a gating
186
+ trigger with no `response`, refuses everyone — and a raise refuses too, because the gate is
187
+ seeded with a deny it keeps when the stack throws. Both failure modes lock the door, so the
188
+ risk to plan for is a self-inflicted lockout, not a breach: guard every drill inside a gate
189
+ with `ref(path, { safe: true })`, since `db.get` binds `null` on a miss. `export()` warns on
190
+ the missing `response`; nothing can warn about the raise. Gating is opt-in — a server with
191
+ no `connect` trigger admits everyone.
192
+ - **Only `null` drops a message.** In a `deliver` trigger (per recipient) and in a message
193
+ handler, `false`/`0`/`""` all deliver the message unchanged, and a crash broadcasts the
194
+ sender's original unvalidated payload. Return `null` to suppress. So a redaction check
195
+ written as a boolean sends the very message it was meant to hide. Per-viewer redaction
196
+ also takes **two objects**: the `deliver` trigger *and* `delivery: { perRecipient: true }`
197
+ on its channel. Either half alone delivers the payload unchanged to everyone, so a gate
198
+ whose return semantics are perfect still ships unredacted if the flag is missing — and the
199
+ flag costs a stack per recipient per message, so it is opt-in. `export()` warns on both
200
+ halves.
201
+ - **`conversation: { enabled: true }` alone stores nothing.** `limit` defaults to `0`, and
202
+ `0` means retain none. Always pass a `limit`. What a handler broadcasts *is* the stored
203
+ row, so broadcast everything a future joiner needs to render it.
204
+ - **An idle socket is reaped after ~10 minutes.** A listen-only client (feed, dashboard,
205
+ presence sidebar) must send `{ action: "ping" }` or any frame periodically, or it silently
206
+ drops and reconnects forever.
207
+ - **`s.realtime.publish` is the push direction, and it is fail-soft.** It bypasses the
208
+ channel's `publish.who` (authorization belongs in your stack), does not invoke the named
209
+ message's handler, and swallows a missing or disabled server — a mis-targeted publish is
210
+ silent. Pass the server handle and a filled-in path (`room.getChannel({ room_id: 42 })`),
211
+ never the template — a constant channel still carrying `{param}` throws at author time, and
212
+ a constant server or channel naming nothing this workspace registers warns at export.
213
+
214
+ **The superseded realtime layer.** Xano has had two realtime generations and they reuse the
215
+ same words. `realtimeTrigger(...)` and `s.api.realtime_event(...)` belong to the old
216
+ workspace-global layer; they are supported only so `codegen` can bring back a workspace that
217
+ holds them, and they are named in `llms/legacy.md` rather than in the authoring catalogs.
218
+ Aiming `s.api.realtime_event` at a current-layer channel publishes into the void — use
219
+ `s.realtime.publish({ server, channel, data })`, which names the owning server and so can
220
+ resolve the channel.
221
+
222
+ **MCP servers & agents** — both persist under the `toolset` payload key, so an `mcpServer`
223
+ and an `agent` **sharing a name collide**. A `tool({...})` is its own kind, referenced by
224
+ handle from either.
225
+
226
+ ```ts
227
+ // Auth is PER-TOOL and works like a query's: name an auth table({ auth: true }).
228
+ mcpServer({ name: "books", tools: [{ tool: searchTool, auth: users }] });
229
+
230
+ const assistant = agent({
231
+ name: "assistant",
232
+ llm: { type: "xano-free", systemPrompt: "Be helpful.", prompt: "Answer the question." },
233
+ // Pass the handles directly; the `{ tool, enabled?, auth? }` wrapper (above)
234
+ // is only for per-tool auth or `enabled: false`.
235
+ tools: [searchTool],
236
+ });
237
+
238
+ // Agents have NO public endpoint — invoke them in-stack from any host with a stack.
239
+ query({
240
+ name: "ask", verb: "POST", apiGroup: api,
241
+ input: { question: input.text({ required: true }) },
242
+ stack: [s.ai.agent.run({ agent: assistant, args: obj({ question: inp("question") }), as: "answer" })],
243
+ response: { text: ref("answer.result") },
244
+ });
245
+ ```
246
+
247
+ - **The run result is an envelope, not the completion.** The model's text is at **`.result`**
248
+ — `ref("answer")` is the whole metadata object (`finishReason`, `steps`, …). Both are
249
+ typed, so `InferResponse` reflects either.
250
+ - **`llm` is a provider-discriminated union** — `anthropic` / `openai` / `google-genai` /
251
+ `xano-free` (which needs no API key) — each with its provider's typed fields.
252
+ - **Structured output types the call site.** Author `output: { schema: { … } }` on the agent
253
+ with the `input.*` catalog and `.result` is typed from it wherever the handle is passed —
254
+ no second witness. The type-only `resultShape` is only for overriding that, or for an
255
+ agent referenced by bare name.
256
+ - **String settings are Twig-templated at run time.** The `args` you pass to
257
+ `s.ai.agent.run` become `{{ $args }}` (env vars are `{{ $env.NAME }}`), which is how an
258
+ endpoint's inputs reach the prompt. Numeric and boolean fields are not templated. Build a
259
+ dynamic arg with `obj({...})`, not `c.obj`.
260
+ - **`mcpServer().getUrl(HOST)`** derives the Streamable-HTTP endpoint from the def, the same
261
+ contract as `query.getPath()`. Resolve once — handing the result back in as a `HOST` throws
262
+ rather than append a second endpoint path. Agents expose only `getCanonical()`.
263
+
264
+ **Background execution.** `s.function.run` and `s.ai.agent.run` take a `runtime` block
265
+ (`{ mode: "async-shared" }`, or `"async-dedicated"` with `cpu`/`memory`/`timeout`/`maxRetry`)
266
+ that moves the call off the request path. This is **not** a performance knob: Xano rewrites
267
+ an async call to a statement that dispatches and continues, so it does not return the
268
+ function's result — don't bind `as` expecting a value. Collect results later with
269
+ `s.await({ ids })`.
270
+
271
+ ## Microservices
272
+
273
+ A microservice is a container workload deployed alongside the workspace and called from a
274
+ stack with `s.microservice.request`. Two mutually exclusive shapes chosen by `kind`: `builtin`
275
+ declares containers (image/ports/resources/env/command/args) plus optional `ingresses`, and
276
+ `helm` points at a chart and its `values`; passing both throws.
277
+
278
+ ```ts
279
+ export const echo = microservice({
280
+ name: "echo",
281
+ deployment: {
282
+ replicas: 2,
283
+ containers: [{
284
+ name: "echo",
285
+ image: "ealen/echo-server:latest",
286
+ ports: [{ servicePort: "8080", containerPort: "80" }],
287
+ resources: { cpu: "50m", ram: "256Mi" },
288
+ }],
289
+ },
290
+ });
291
+ ```
292
+
293
+ Call it by passing the def itself. `port` folds into the single `"name:port"` host string
294
+ the engine reads, and is optional — a microservice exposing exactly one `servicePort`
295
+ resolves to it, and one exposing several requires it. A port the microservice doesn't expose
296
+ is a type error where the def's ports are known, and a build-time throw otherwise:
297
+
298
+ ```ts
299
+ s.microservice.request({ as: "res", host: echo, path: "/health" });
300
+ ```
301
+
302
+ Only `host` and `path` are required. `method`, `params`, `headers`, `timeout`, and
303
+ `follow_location` default to the engine's own values (`GET`, `{}`, `[]`, `10`, `true`) and are
304
+ always written — this statement's schema requires them, so they can't be left off the wire;
305
+ you just don't have to type them.
306
+
307
+ `host` binds by name, not by guid, because that is how the engine resolves it — so renaming
308
+ a microservice fixes every call site at once. A plain `"name:port"` string is also accepted
309
+ and is the only way to reach an instance-level microservice, which isn't a workspace object;
310
+ nothing checks that spelling, so prefer the def wherever there is one.
311
+
312
+ A container takes time to come up, so `xanots deploy` waits for it: after the import it
313
+ reads each microservice and reports whether it is ready, still starting, or failed, then
314
+ lists them. Skip the wait with `--no-verify`. The same report is available any time from
315
+ `xanots ephemeral get <env>`, `xanots sandbox details`, and `xanots workspace details`.
316
+
317
+ Two outcomes, and only one of them is a warning:
318
+
319
+ - **The engine reports the microservice broken** (an image that won't pull, a container that
320
+ won't start) — the deploy **exits 4**. Waiting longer cannot change that answer, and a URL
321
+ and a ✓ printed over a dead workload is not a successful deploy. This is the default; there
322
+ is no flag to turn it off.
323
+ - **It simply hasn't reported ready by the end of the wait** — a warning, exit `0`. The
324
+ backend is live and a slow container usually follows moments later. Pass
325
+ **`--require-microservices`** to make that exit 4 too, which is what CI wants: nobody is
326
+ there to find out whether "should come up shortly" happened.
327
+
328
+ Exit 4 is the microservice sibling of exit 3 (a `--static` upload that failed while the
329
+ backend deploy stood): the import committed, and something it deployed is not serving. The
330
+ URL and the JSON summary still print either way — the exit code is what carries the
331
+ difference.
332
+
333
+ `tenantDeploy: "manual"` rows are reported but never waited on — nothing starts them for you.
334
+ Reach for it when the row should exist without a workload behind it; `examples/sandbox` uses
335
+ it so deploying the examples doesn't wait on containers.
336
+
337
+ Container names are free-form: they need not match the microservice's own name, and nothing
338
+ about addressing depends on them. A stack reaches the **microservice** name (plus a
339
+ `servicePort`), whichever containers sit behind it, so a multi-container workload names each
340
+ one for what it is.
341
+
342
+ **This surface is early and expected to change**, and every export of a workspace declaring a
343
+ microservice prints a notice saying so — the docs are read before writing, which is not where
344
+ you are when it matters. `configs` and `volumes` are typed and `@deprecated` but **not
345
+ deployable**: the engine rejects an import carrying either, so `export()` fails the build
346
+ rather than letting the deploy fatal minutes in, after provisioning has begun. Declare a value
347
+ the workload reads as a container `env` entry, and storage as a container `volumes` entry
348
+ (`emptyDir`, `persistent`, or `config`). Both fields stay typed so a pulled workspace holding
349
+ one still decodes.
350
+
351
+ Two fields carry secrets into the bundle — and into a pulled tree — verbatim:
352
+ `chart.values` and `registryAuth.dockerconfigjson`, because otherwise a pulled microservice
353
+ could not be redeployed.
354
+
355
+ **What "out of band" can and cannot mean here.** Both are stored strings the engine keeps
356
+ exactly as given, with no deploy-time indirection — no `env()` form, no template the tenant
357
+ resolves. So the spelling that looks safe is the wrong one:
358
+
359
+ ```ts
360
+ // WRONG — `process.env` resolves at EXPORT time. The literal credential is written
361
+ // into workspace.json, and into git with it.
362
+ microservice({ name: "app", registryAuth: { dockerconfigjson: process.env.REGISTRY_JSON! } });
363
+ ```
364
+
365
+ Two honest options, both about where the bytes live rather than about hiding them:
366
+
367
+ 1. **Leave `registryAuth` unset** — a public image, or a pull credential attached to the
368
+ microservice outside this workspace. Nothing then carries a credential.
369
+ 2. **Accept that the tree is secret-bearing** — keep `workspace.json` and any pulled tree out
370
+ of git, or rotate the credential once it lands there.
371
+
372
+ Export prints a notice naming every microservice whose bundle bytes carry either field, so
373
+ this can't happen quietly; `--strict` does **not** promote it, since shipping a
374
+ private-registry workload is a legitimate end state. When what you actually need is a secret
375
+ your *stack* reads, the mapped surface is `workspaceConfig({ env })` + `env("NAME")` — see
376
+ [Middleware, request history &amp; env vars](#middleware-request-history--env-vars).
@@ -0,0 +1,43 @@
1
+ # Project structure
2
+
3
+ How a XanoTS project is laid out on disk, and why registration is explicit.
4
+
5
+ Lay objects out however you like and register them explicitly — there's no folder
6
+ auto-discovery magic (deliberately):
7
+
8
+ ```
9
+ xano/
10
+ ├── function/ get_user.ts export const getUser = defineFunction({...})
11
+ ├── table/ table.ts export const user = table({...})
12
+ │ └── trigger/ on_insert.ts export const onInsert = tableTrigger({...})
13
+ ├── query/ public.ts export const publicApi = apiGroup({...})
14
+ │ public/posts_GET.ts export const posts = query({...})
15
+ ├── agent/ assistant.ts export const assistant = agent({...})
16
+ ├── realtime_server/ chat.ts export const chat = realtimeServer({...})
17
+ │ chat/room.ts export const room = realtimeChannel({...})
18
+ │ chat/room/send.ts export const send = realtimeMessage({...})
19
+ ├── workspace.ts export const workspaceSettings = workspaceConfig({...})
20
+ └── index.ts workspace("my-app").registerTables([...]).registerFunctions([...])…
21
+ ```
22
+
23
+ Objects nest under whatever owns them. Anything with children — an API group, a
24
+ realtime server, a channel — is a file named for itself sitting *beside* the folder
25
+ holding its children, so `chat.ts` opens in a tab you can tell apart and a group with
26
+ no queries needs no folder at all. Realtime is the deepest, being the only three-level
27
+ hierarchy in a workspace — server, then channel, then message — and a trigger sits in
28
+ a `trigger/` folder at whichever level it fires on.
29
+
30
+ Paths are lower case throughout — an HTTP verb is the one exception, because it is
31
+ the method rather than a word. Bindings keep the object's own casing, so a file name
32
+ and the symbol it exports can differ.
33
+
34
+ That is the shape `xanots codegen` writes, and its `index.ts` re-exports every object
35
+ by name — import from the tree's root rather than from a file, since a file path moves
36
+ when an object's parent or its `_shared.ts` placement changes. Hand-authored projects are
37
+ free to use any other layout; only `index.ts` registering the objects matters.
38
+
39
+ `workspace("my-app")` is the natural entry point — sugar for
40
+ `new Xano().registerWorkspace({ name: "my-app" })`, returning the same chainable registry.
41
+ Authoring is **declarative def-objects** passed to factories; there is no callback/chaining
42
+ builder. `xano.export()` returns the importable `packageExport` bundle, and
43
+ `xanots export`/`deploy` read the module's default export.
@@ -0,0 +1,198 @@
1
+ # The scaffolded project
2
+
3
+ What `xanots init` writes, the two frontend presets, theming, add-ons, and the SvelteKit prerendering rules.
4
+
5
+ ## init flags and add-ons
6
+
7
+ `init` flags: `--framework <react|svelte>` (default: `react`), `--name <name>`
8
+ (default: the folder name), `--theme <id>` / `--radius <len>` / `--dark <mode>` /
9
+ `--font <id>` / `--font-mono <id>` / `--font-heading <id>` / `--icons <id>`
10
+ (the look — see [Theming](#theming)), `--ai <claude|codex|cursor|none>`
11
+ (repeatable; writes `CLAUDE.md`/`AGENTS.md`/Cursor rules — none by default),
12
+ `--marketplace <pkg>` (repeatable, comma-separated; installs add-ons and
13
+ registers them — below), `--force` (scaffold into a non-empty folder),
14
+ `--no-install` (skip `npm install`).
15
+ In a terminal, `init` prompts for the framework, the theme, and the AI files;
16
+ every prompt has a default, so pressing enter three times is a valid answer.
17
+ The starter backend is empty but already compiles and deploys — grow it from the
18
+ walkthrough in `xano/EXAMPLE.md`.
19
+
20
+ A scaffold ships `@xanots/sdk` and nothing else from the `@xanots` scope. Add-ons
21
+ install on demand:
22
+
23
+ ```bash
24
+ xanots marketplace list # every published add-on
25
+ xanots marketplace search auth # …or narrow by keyword
26
+ xanots marketplace details @xanots/auth # what it installs + how to register it
27
+ xanots marketplace install @xanots/auth # add it to the project you're in
28
+ ```
29
+
30
+ The three read verbs hit a public catalogue, so they work before you log in.
31
+ Every add-on is optional and none is assumed by anything in the scaffold —
32
+ install one when you need it.
33
+
34
+ `init` takes the same package names, so a project can be scaffolded with its
35
+ add-ons already wired:
36
+
37
+ ```bash
38
+ xanots init my-app --framework react --marketplace @xanots/auth,@xanots/vector
39
+ ```
40
+
41
+ That installs each package **and** writes its registration into `xano/index.ts`.
42
+ Installing alone would leave dependencies nothing imports — a different project
43
+ wearing the same name. A module declares how it registers in its own
44
+ `package.json` (`"xanots": { "register": "registerAuth" }`); one that has not
45
+ adopted the field is read for a single `register*` export, and two of those
46
+ without the field is a refusal rather than a guess.
47
+
48
+ `@xanots/auth` is **authentication, not authorization** — user/login/signup
49
+ tables and the endpoints over them. It ships no roles, permissions, or route
50
+ guards, so it is not the RBAC answer. Build role guards natively: a role column
51
+ on the auth table, then a `s.precondition` on each endpoint gating
52
+ `auth("role")`.
53
+
54
+ `details` is the one to reach for when wiring an add-on: it prints the objects
55
+ the add-on puts on your workspace, what you have to supply, and the
56
+ `xano/index.ts` registration to copy. Piped, it emits JSON; `--prompt` emits
57
+ instructions written to be handed straight to a coding agent.
58
+
59
+ That is `npm install` with two additions: add-ons are discoverable from `xanots
60
+ --help`, and the command refuses before npm runs when you are not standing in a
61
+ project — the mistake npm answers by silently writing to the wrong `package.json`.
62
+ The package name is passed through exactly as typed, so version specifiers, tags,
63
+ and third-party packages all work.
64
+
65
+ ## The frontend preset
66
+
67
+ To point `npm run dev` at a real backend, copy `.env.example` to `.env.local` — both
68
+ live at the **project root**, next to `vite.config.ts` — and set `VITE_XANO_HOST` to a
69
+ deployed URL. Deployed builds don't need it: `xanots deploy <entry> --static <dir>` injects the
70
+ backend URL as `window.XANO_HOST`, which takes precedence.
71
+
72
+ The frontend ships `Button` and `Card` already vendored, plus a pre-configured
73
+ `components.json`, so `npx shadcn@latest add dialog form input` (or
74
+ `npx shadcn-svelte@latest add …` on a Svelte scaffold) works immediately — no
75
+ `init` step for either CLI. Components are copied into your repo rather than
76
+ installed, so you own and edit them directly. [Lucide](https://lucide.dev/icons)
77
+ is installed on both scaffolds — `lucide-react` on React, `@lucide/svelte` on
78
+ Svelte — and the landing page already uses it.
79
+
80
+ ## Theming
81
+
82
+ shadcn components carry no colors of their own: they are Tailwind utilities over
83
+ a fixed set of semantic tokens (`--primary`, `--muted-foreground`, `--border`,
84
+ the `--chart-*` ramp, the `--sidebar-*` set). Those tokens live at the top of
85
+ `frontend/src/index.css`, which is the whole theme — Tailwind v4 keeps it in CSS,
86
+ and there is no `tailwind.config.js`. That one stylesheet backs both frameworks.
87
+
88
+ `init` renders it from a theme you choose, using shadcn's own two-part model:
89
+
90
+ ```bash
91
+ xanots init my-app --theme zinc-blue # a base color, plus an accent over it
92
+ xanots init my-app --theme stone # a base color alone
93
+ xanots init my-app --theme zinc --radius 0
94
+ ```
95
+
96
+ Base colors — the full token set: `neutral` (default), `stone`, `zinc`, `mauve`,
97
+ `olive`, `mist`, `taupe`. Accents — a partial override of `primary`, `secondary`,
98
+ the chart ramp, and the sidebar primary: `amber`, `blue`, `cyan`, `emerald`,
99
+ `fuchsia`, `green`, `indigo`, `lime`, `orange`, `pink`, `purple`, `red`, `rose`,
100
+ `sky`, `teal`, `violet`, `yellow`. The values are shadcn's, verbatim, so
101
+ `--theme zinc-blue` is what ui.shadcn.com hands out for the same pair.
102
+
103
+ `--theme` also takes any shadcn **registry theme** — its own, a third-party
104
+ generator's, or your team's:
105
+
106
+ ```bash
107
+ xanots init my-app --theme https://ui.shadcn.com/r/themes/slate.json
108
+ xanots init my-app --theme ./brand-theme.json
109
+ ```
110
+
111
+ `--radius <len>` overrides the corner radius (a bare number is rem). Everything
112
+ else about the project is identical whichever theme you pick, and you can change
113
+ your mind later by editing the token values — or by applying another theme over
114
+ them with `npx shadcn@latest add <registry-theme-url>`.
115
+
116
+ ### Dark mode
117
+
118
+ Every theme ships a complete dark palette. `--dark` decides what turns it on:
119
+
120
+ - `system` (default) — an inline script in the HTML entry applies the OS setting
121
+ before first paint, so the page never flashes light first. No UI.
122
+ - `toggle` — that, plus `frontend/src/lib/theme.ts` (the persisted mode) and a
123
+ mode toggle on the landing page cycling system → light → dark.
124
+ - `off` — light only. The `.dark` block is still there and still complete.
125
+
126
+ Whichever you pick, style with the token classes (`bg-primary`,
127
+ `text-muted-foreground`) rather than raw palette classes like `bg-gray-100`:
128
+ raw ones ignore the theme and are unreadable in dark mode. The scaffolded AI
129
+ instruction files say so too.
130
+
131
+ ### Typefaces and icons
132
+
133
+ Fonts are opt-in and **self-hosted**: each choice installs an `@fontsource`
134
+ package rather than linking Google's CDN, which would be a third-party request
135
+ on every page load of your app, a failure behind a firewall, and a privacy
136
+ question someone inherits later.
137
+
138
+ ```bash
139
+ xanots init my-app --font geist --font-heading instrument-serif --icons tabler
140
+ ```
141
+
142
+ - `--font <id>` — body text (Tailwind's `--font-sans`, which v4 also uses as the
143
+ page default). Sans faces only: `geist`, `inter`, `figtree`, `manrope`,
144
+ `dm-sans`, `space-grotesk`, `outfit`, and 10 more.
145
+ - `--font-mono <id>` — code. `jetbrains-mono` or `geist-mono`.
146
+ - `--font-heading <id>` — headings. Accepts **any** face, sans or serif, since a
147
+ display serif over a sans body is the reason the slot exists. It emits a
148
+ base-layer rule, so headings pick it up without touching every `<h1>`.
149
+ - Omit a slot and it keeps Tailwind's default stack, installing nothing for it.
150
+
151
+ An unknown id fails at `init` naming the slot it was resolving, so `--font-mono
152
+ inter` is an error rather than a proportional face quietly rendering your code
153
+ blocks.
154
+
155
+ `--icons <id>` picks the icon set: `lucide` (default), `tabler`, or `phosphor`.
156
+ The binding covers the dark-mode toggle's icons as well as the landing page's —
157
+ without that, `--icons tabler --dark toggle` would emit a toggle importing a
158
+ library the project no longer installs, a build failure from a flag with nothing
159
+ to do with dark mode.
160
+
161
+ > On a Svelte scaffold `npm run typecheck` runs `svelte-kit sync && svelte-check`
162
+ > rather than `tsc`. It checks the backend and the components together — `tsc`
163
+ > cannot read `.svelte` files at all.
164
+
165
+ > **The SvelteKit scaffold prerenders every route.** `frontend/src/routes/+layout.ts`
166
+ > sets `prerender = true`, so each route becomes its own HTML document at build
167
+ > time and loads as a real page. Pages live in `frontend/src/routes/`, and `files`
168
+ > in the `sveltekit()` plugin config keeps the project single-rooted with `xano/`
169
+ > as a peer. That config lives in `vite.config.ts` — there is no
170
+ > `svelte.config.js`, matching where SvelteKit's own scaffold now puts it.
171
+ >
172
+ > There is still no server at runtime — Xano is the backend and `deploy --static`
173
+ > ships to a host with no runtime, so `+page.server.ts`, form actions, and server
174
+ > `load` have nothing to run on, and the build does not stop you. Treat them as
175
+ > unavailable rather than trusting a green build.
176
+ >
177
+ > Two things follow from prerendering. Because it renders at build time,
178
+ > module-scope `window`/`document` access fails the **build** rather than the
179
+ > browser — use `onMount`, or guard with `browser`. And a dynamic route like
180
+ > `/posts/[id]` **fails the build** unless it declares which ids exist:
181
+ >
182
+ > ```ts
183
+ > // frontend/src/routes/posts/[id]/+page.ts
184
+ > export const entries = () => [{ id: "1" }, { id: "2" }];
185
+ > ```
186
+ >
187
+ > That is deliberate — a loud build error beats shipping a page that 404s for
188
+ > real users. Unmatched paths get a real 404 from
189
+ > `frontend/src/routes/404/+page.svelte`, which prerenders to `404.html`. It has
190
+ > to be a route: SvelteKit never prerenders `+error.svelte` to a file, so that
191
+ > alone would ship no `404.html` and every unknown path would serve the home page
192
+ > with a 200 instead.
193
+ >
194
+ > One more build-time check comes with prerendering: a hash link to an id that is
195
+ > not on the page it renders on — `<a href="#pricing">` with no `id="pricing"` —
196
+ > **fails the build**, naming the route and the id. A hash nav in
197
+ > `+layout.svelte` is exempt on `/404` only, since that route inherits the layout
198
+ > and by definition carries none of the page's sections.