@pylonsync/create-pylon 0.20.0 → 0.22.0

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 (43) hide show
  1. package/package.json +1 -1
  2. package/templates/_root/AGENTS.md +1 -1
  3. package/templates/agency/AGENTS.md +1 -1
  4. package/templates/ai-chat/AGENTS.md +1 -1
  5. package/templates/ai-studio/AGENTS.md +1 -1
  6. package/templates/barebones/AGENTS.md +1 -1
  7. package/templates/chat/AGENTS.md +1 -1
  8. package/templates/chat/app/chat-client.tsx +1 -1
  9. package/templates/consumer/AGENTS.md +1 -1
  10. package/templates/consumer/components/social/post-parts.tsx +1 -1
  11. package/templates/consumer/components/social/post-screen.tsx +1 -1
  12. package/templates/consumer/components/social/use-social.ts +7 -7
  13. package/templates/creator/AGENTS.md +1 -1
  14. package/templates/crm/AGENTS.md +1 -1
  15. package/templates/directory/AGENTS.md +1 -1
  16. package/templates/helpdesk/AGENTS.md +1 -1
  17. package/templates/imessage/.env.example +2 -4
  18. package/templates/imessage/AGENTS.md +5 -4
  19. package/templates/imessage/README.md +14 -14
  20. package/templates/imessage/app/console.tsx +3 -3
  21. package/templates/imessage/components/setup-view.tsx +5 -9
  22. package/templates/imessage/functions/assistant.ts +17 -34
  23. package/templates/imessage/functions/processTurn.ts +14 -13
  24. package/templates/imessage/functions/resolveTurn.ts +10 -15
  25. package/templates/imessage/functions/setupStatus.ts +2 -3
  26. package/templates/imessage/lib/turn.ts +25 -0
  27. package/templates/imessage/lib/urls.ts +20 -0
  28. package/templates/imessage/tests/turn.test.ts +141 -0
  29. package/templates/inventory/AGENTS.md +1 -1
  30. package/templates/invoices/AGENTS.md +1 -1
  31. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +2 -2
  32. package/templates/local-service/AGENTS.md +1 -1
  33. package/templates/marketplace/AGENTS.md +1 -1
  34. package/templates/marketplace/client/WatchButton.tsx +2 -2
  35. package/templates/projects/AGENTS.md +1 -1
  36. package/templates/projects/app/projects/[id]/project-view.tsx +2 -2
  37. package/templates/restaurant/AGENTS.md +1 -1
  38. package/templates/saas/AGENTS.md +1 -1
  39. package/templates/shop/AGENTS.md +1 -1
  40. package/templates/todo/AGENTS.md +1 -1
  41. package/templates/waitlist/AGENTS.md +1 -1
  42. package/templates/imessage/lib/agent-call.ts +0 -251
  43. package/templates/imessage/tests/agent-call.test.ts +0 -112
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -54,7 +54,7 @@ this pass before the user has to ask.
54
54
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
55
55
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` or `<Suspense>`. The HTTP head commits when the shell is ready. Status, headers, and cookies set from a suspended subtree are lost, and `redirect` or `notFound` thrown below a Suspense boundary are swallowed.
56
56
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are available on mutations and actions, not queries.** An `action` has no direct `ctx.db`; use `ctx.runQuery` or `ctx.runMutation`.
57
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
57
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
58
58
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
59
59
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
60
60
  - **There is no `ctx.files` or `defineWorkflow`/`defineJob`.** Files go through `<FileUpload>` + `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; deferred execution is `ctx.scheduler.runAfter/runAt/cancel`.
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
68
  - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
 
68
68
  Streaming agent turn — tokens to the caller, and to every device watching:
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
 
68
68
  Streaming agent turn — tokens to the caller, and to every device watching:
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
68
  - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
 
68
68
  Streaming agent turn — tokens to the caller, and to every device watching:
@@ -254,7 +254,7 @@ function Chat({ selfId, initialChannel }: { selfId: string; initialChannel?: str
254
254
 
255
255
  function remove(id: string) {
256
256
  if (window.confirm("Delete this message? Everyone in the channel will stop seeing it.")) {
257
- void db.delete("Message", id);
257
+ db.delete("Message", id).catch(console.warn);
258
258
  }
259
259
  }
260
260
 
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
68
  - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
@@ -86,7 +86,7 @@ export function PostMenu({ social, post }: { social: Social; post: Post }) {
86
86
  className={cn(row, "font-semibold text-red-600")}
87
87
  onClick={() => {
88
88
  setOpen(false);
89
- void db.delete("Post", post.id);
89
+ db.delete("Post", post.id).catch(console.warn);
90
90
  if (window.location.pathname.startsWith("/p/")) router.push("/");
91
91
  }}
92
92
  >
@@ -158,7 +158,7 @@ function CommentRow({
158
158
  {mine ? (
159
159
  <button
160
160
  type="button"
161
- onClick={() => void db.delete("Comment", comment.id)}
161
+ onClick={() => db.delete("Comment", comment.id).catch(console.warn)}
162
162
  className="hidden items-center gap-1 hover:text-zinc-900 group-hover:inline-flex"
163
163
  aria-label="Delete comment"
164
164
  >
@@ -66,21 +66,21 @@ export type Social = ReturnType<typeof useSocial>;
66
66
  export const actions = {
67
67
  toggleLike(social: Social, postId: string) {
68
68
  const mine = social.myLikes.get(postId);
69
- if (mine) void db.delete("Like", mine.id);
70
- else if (social.me) void db.insert("Like", { postId, userId: social.me });
69
+ if (mine) db.delete("Like", mine.id).catch(console.warn);
70
+ else if (social.me) db.insert("Like", { postId, userId: social.me }).catch(console.warn);
71
71
  },
72
72
  like(social: Social, postId: string) {
73
- if (social.me && !social.myLikes.has(postId)) void db.insert("Like", { postId, userId: social.me });
73
+ if (social.me && !social.myLikes.has(postId)) db.insert("Like", { postId, userId: social.me }).catch(console.warn);
74
74
  },
75
75
  toggleSave(social: Social, postId: string) {
76
76
  const mine = social.mySaves.get(postId);
77
- if (mine) void db.delete("Save", mine.id);
78
- else if (social.me) void db.insert("Save", { postId, userId: social.me });
77
+ if (mine) db.delete("Save", mine.id).catch(console.warn);
78
+ else if (social.me) db.insert("Save", { postId, userId: social.me }).catch(console.warn);
79
79
  },
80
80
  toggleFollow(social: Social, userId: string) {
81
81
  if (!social.me || userId === social.me) return;
82
82
  const mine = social.myFollows.get(userId);
83
- if (mine) void db.delete("Follow", mine.id);
84
- else void db.insert("Follow", { followingId: userId, followerId: social.me });
83
+ if (mine) db.delete("Follow", mine.id).catch(console.warn);
84
+ else db.insert("Follow", { followingId: userId, followerId: social.me }).catch(console.warn);
85
85
  },
86
86
  };
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
68
  - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
68
  - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
68
  - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
@@ -62,7 +62,7 @@ this pass before the user has to ask.
62
62
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
63
63
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
64
64
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
65
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
65
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
66
66
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
67
67
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
68
68
  - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
@@ -42,8 +42,6 @@ IMESSAGE_TRANSPORT=sendblue
42
42
  # IMESSAGE_ALLOW_DEV_SENDS=1
43
43
 
44
44
  # ── Production only ──────────────────────────────────────────────────────────
45
- # The server runs the agent as the owner by calling its own API. It needs its
46
- # public origin and the admin token to open that session. `pylon dev` needs
47
- # neither (it falls back to http://127.0.0.1:$PYLON_PORT).
45
+ # The public origin the Setup page uses for the webhook and relay URLs.
46
+ # `pylon dev` falls back to http://127.0.0.1:$PYLON_PORT.
48
47
  # APP_URL=https://assistant.example.com
49
- # PYLON_ADMIN_TOKEN=
@@ -14,9 +14,10 @@ transports, the owner model, and production setup.
14
14
  on the transport's message id, allowlist and rate limit (`lib/gate.ts`), then
15
15
  schedule `processTurn`. Do not add a second path that skips it.
16
16
  - **The agent is `functions/assistant.ts` (`agent()`, `auth: "admin"`).**
17
- `processTurn` runs it as the owner through `lib/agent-call.ts`. Tools must
18
- resolve their contact with `currentTurn(ctx)` and never accept a contact,
19
- handle, or id from the model.
17
+ `processTurn` runs it as the owner with `ctx.agents.run`, passing the
18
+ conversation and turn as the run's `context`. Tools must resolve their
19
+ contact with `currentTurn(ctx, run)` and never accept a contact, handle, or
20
+ id from the model.
20
21
  - **The dashboard is owner-only.** Every entity policy is `auth.isAdmin`; the
21
22
  owner is the verified email in `PYLON_ADMIN_EMAILS`.
22
23
  - **AppleScript:** `relay/applescript.ts` passes text and handle as argv to a
@@ -68,7 +69,7 @@ transports, the owner model, and production setup.
68
69
  - **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
69
70
  - **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready; status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
70
71
  - **`ctx.llm`, `ctx.rooms`, and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db`; use `ctx.runQuery` / `ctx.runMutation`.
71
- - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. Streaming does NOT extend the call deadline (`PYLON_FN_CALL_TIMEOUT`, 30s default) — set `timeout: <secs>` on the def for a long run.
72
+ - **`ctx.llm.stream(request, onEvent)` streams tokens as they generate** and still resolves with the full response, so `stop_reason === "tool_use"` drives an agent tool loop. Events are `text_delta` / `tool_use_start` / `tool_input_delta` / `done`. Same auth + model-allowlist gating as `ctx.llm.complete`. The call timeout is an idle timeout (`PYLON_FN_CALL_TIMEOUT`, 30s default; Stack0 Cloud 300s): each stream event resets it, and the total run is capped at 10× the timeout. Set `timeout: <secs>` on the def when a step (a slow tool call) can be silent longer than that.
72
73
  - **`ctx.stream.write(text)` streams to the caller — and every fn stream is RESUMABLE**: the server buffers frames under a stream id (`streamFn`'s `onStreamId`), so a dropped connection or closed tab catches up via `resumeStream(id)`, including the final result after the handler finished. `ctx.rooms.broadcast(room, topic, data)` fans out to every CURRENTLY-CONNECTED subscriber (second device, second tab) but does not replay missed messages — use the stream id for anything that must survive a gap. Broadcast resolves `{ delivered: false }` when the room has no members — a no-op, not an error. Clients receive with `useRoomMessages(room, cb)` from `@pylonsync/react`; `useRoom` is the send side.
73
74
  - **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
74
75
  - **Use the supported file and scheduling APIs.** Files go through `<FileUpload>` and `/api/files/*`; an upload is readable by its uploader only, so pass `visibility: "public"` to `uploadFile` or `<FileUpload>` for files every visitor sees; there is no `ctx.files`. One-shot work uses `ctx.scheduler.runAfter`, `runAt`, or `cancel`; there is no `defineWorkflow` or `defineJob`. Recurring work uses `cron("0 * * * *", "fnName")` in `buildManifest({ crons: [...] })`, imported from `@pylonsync/sdk`. Make the target function `internal: true`. It runs with anonymous auth, but its own `ctx.db.*` calls are server-side and bypass policies. Use `ctx.auth.elevate({ admin: true, reason: "..." })`, with a mandatory reason, only when chaining another internal function through `ctx.scheduler`.
@@ -55,8 +55,8 @@ real texts arrive.
55
55
  history; after 80 turns a new run starts with the last few texts as context.
56
56
  - **Tools.** `remember_note`, `recall_notes`, `forget_note`, `set_reminder`,
57
57
  `list_reminders`, `cancel_reminder`. They act only on the person being
58
- answered: the contact comes from the server (`functions/resolveTurn.ts`),
59
- never from the model. Reminders run on Pylon's job scheduler
58
+ answered: the contact comes from the server (the run's context and
59
+ `functions/resolveTurn.ts`), never from the model. Reminders run on Pylon's job scheduler
60
60
  (`ctx.scheduler.runAt` → `functions/fireReminder.ts`).
61
61
  - **Replies** are converted from Markdown to plain text and split into up to
62
62
  four bubbles along paragraph breaks (`lib/chunk.ts`).
@@ -64,15 +64,15 @@ real texts arrive.
64
64
  owner is the account whose verified email is in `PYLON_ADMIN_EMAILS`.
65
65
  Anonymous visitors and other accounts read nothing.
66
66
 
67
- ### Why the server calls itself
67
+ ### How a turn runs the agent
68
68
 
69
- `agent()` stores each run under the signed-in user who called it. A webhook
70
- has no user, so `processTurn` opens a session for the owner
71
- (`POST /api/auth/session`) and calls `/api/fn/assistant` over HTTP
72
- (`lib/agent-call.ts`). The session token stays in server memory. In
73
- `pylon dev` this needs nothing. In production set `PYLON_ADMIN_TOKEN` (only the
74
- server uses it, to open that session). The call goes to `127.0.0.1:$PYLON_PORT`
75
- when the port is known, otherwise to `APP_URL`, which must be https.
69
+ `agent()` stores each run under a user. A webhook has no user, so
70
+ `processTurn` (an internal action) calls `ctx.agents.run("assistant", ...)` with
71
+ `as: { userId: <owner>, admin: true }`. The run belongs to the owner and shows
72
+ up live in the dashboard. The call also passes `context: { conversationId,
73
+ turnId }`; the tools read it from their `run` argument, and `resolveTurn`
74
+ maps it to the contact only while that turn is running. Only server code can
75
+ set a run's context.
76
76
 
77
77
  ## Transport 1: Sendblue
78
78
 
@@ -155,7 +155,7 @@ How the relay works (`relay/main.ts`):
155
155
  | `SENDBLUE_*` | The Sendblue transport. |
156
156
  | `RELAY_TOKEN` | The relay transport. At least 24 characters. |
157
157
  | `IMESSAGE_DRY_RUN=1` | Store replies without sending them (both transports). Always on in `pylon dev` unless `IMESSAGE_ALLOW_DEV_SENDS=1`. |
158
- | `PYLON_ADMIN_TOKEN`, `APP_URL` | Production: how the server reaches its own API to run the agent. `APP_URL` must be https and is used only when `PYLON_PORT` is not set. |
158
+ | `APP_URL` | Production: the public origin the Setup page uses for the webhook and relay URLs. |
159
159
 
160
160
  The Setup page shows which of these are set (never their values), when the
161
161
  last text came in, and when the relay last checked in.
@@ -170,8 +170,8 @@ Covers webhook verification (valid, wrong, missing, unconfigured secret),
170
170
  idempotency, the allowlist and rate limit, STOP/START, relay token auth,
171
171
  AppleScript argument passing (including a real `osascript` echo run on macOS
172
172
  with quotes, backslashes, newlines, and unicode), the chat.db reader against a
173
- fixture database built in a temp directory, the agent call over SSE, and
174
- reply chunking. Nothing in the suite sends a message or calls Sendblue.
173
+ fixture database built in a temp directory, the agent turn and its tool
174
+ fence, and reply chunking. Nothing in the suite sends a message or calls Sendblue.
175
175
 
176
176
  ## Deploy
177
177
 
@@ -180,7 +180,7 @@ pylon deploy
180
180
  pylon secrets set PYLON_ADMIN_EMAILS=you@example.com ANTHROPIC_API_KEY=... \
181
181
  IMESSAGE_TRANSPORT=sendblue SENDBLUE_API_KEY=... SENDBLUE_API_SECRET=... \
182
182
  SENDBLUE_FROM_NUMBER=+1... SENDBLUE_WEBHOOK_SECRET=... \
183
- APP_URL=https://<your app> PYLON_ADMIN_TOKEN=<openssl rand -hex 32>
183
+ APP_URL=https://<your app>
184
184
  ```
185
185
 
186
186
  Sign-in codes in production need an email provider (`PYLON_EMAIL_PROVIDER`);
@@ -55,7 +55,7 @@ interface AgentRunRow {
55
55
  steps?: number | null;
56
56
  error?: string | null;
57
57
  updatedAt: string;
58
- title?: string | null;
58
+ context?: { conversationId?: string } | null;
59
59
  }
60
60
  interface AgentMessageRow {
61
61
  id: string;
@@ -242,10 +242,10 @@ export function Console({ view, initialConversationId }: { view: View; initialCo
242
242
  const contact = conversation ? contactById.get(conversation.contactId) : undefined;
243
243
  const threadMessages = conversation ? (messages ?? []).filter((m) => m.conversationId === conversation.id) : [];
244
244
  const unanswered = threadMessages.filter((m) => m.direction === "in" && UNANSWERED.has(m.status)).length;
245
- const runRow = conversation ? (runs ?? []).find((r) => r.id === conversation.agentRunId || r.title === conversation.id) : undefined;
245
+ const runRow = conversation ? (runs ?? []).find((r) => r.id === conversation.agentRunId || r.context?.conversationId === conversation.id) : undefined;
246
246
  const activity: PanelActivity[] = [];
247
247
  if (conversation) {
248
- const runIds = new Set((runs ?? []).filter((r) => r.title === conversation.id).map((r) => r.id));
248
+ const runIds = new Set((runs ?? []).filter((r) => r.id === conversation.agentRunId || r.context?.conversationId === conversation.id).map((r) => r.id));
249
249
  const refusals = new Map<string, string | null>();
250
250
  for (const m of agentMessages ?? []) {
251
251
  if (!runIds.has(m.runId) || m.role !== "user" || !Array.isArray(m.content)) continue;
@@ -9,7 +9,6 @@ export interface SetupStatus {
9
9
  checklist: { key: string; label: string; ok: boolean; hint: string }[];
10
10
  provider: { configured: boolean; provider: string | null };
11
11
  ownerConfigured: boolean;
12
- adminTokenSet: boolean;
13
12
  appUrlSet: boolean;
14
13
  devMode: boolean;
15
14
  baseUrl: string;
@@ -139,7 +138,7 @@ export function SetupView({
139
138
  function StatusGrid({ status, transport }: { status: SetupStatus; transport: TransportStateView | null }) {
140
139
  const online = relayOnline(transport?.relayLastSeenAt ?? null);
141
140
  const transportOk = status.transport !== null && status.checklist.every((c) => c.ok);
142
- const needsProdConfig = !status.devMode && (!status.appUrlSet || !status.adminTokenSet);
141
+ const needsProdConfig = !status.devMode && !status.appUrlSet;
143
142
  return (
144
143
  <div className="mt-8 grid gap-3 sm:grid-cols-2">
145
144
  <StatusCard
@@ -200,13 +199,10 @@ function StatusGrid({ status, transport }: { status: SetupStatus; transport: Tra
200
199
  ok={!needsProdConfig}
201
200
  title="Server"
202
201
  lines={[
203
- `Calls itself at ${status.baseUrl}`,
204
- status.devMode
205
- ? "Development mode: no admin token needed."
206
- : status.adminTokenSet
207
- ? "PYLON_ADMIN_TOKEN set."
208
- : "Set PYLON_ADMIN_TOKEN so the server can open the owner's session for replies.",
209
- ...(!status.devMode && !status.appUrlSet ? ["Set APP_URL to this app's public https origin."] : []),
202
+ `Public URL ${status.baseUrl}`,
203
+ ...(!status.devMode && !status.appUrlSet
204
+ ? ["Set APP_URL to this app's public https origin so the webhook and relay URLs are right."]
205
+ : []),
210
206
  ]}
211
207
  />
212
208
  </div>
@@ -1,30 +1,13 @@
1
- import { agent, v, type ActionCtx } from "@pylonsync/functions";
1
+ import { agent, v } from "@pylonsync/functions";
2
2
  import { ASSISTANT_MODEL, SYSTEM_PROMPT } from "../lib/assistant";
3
-
4
- interface Turn {
5
- conversationId: string;
6
- contactId: string;
7
- }
8
-
9
- // Which contact is this generation about? Every tool starts here. The agent
10
- // loop records its stream id on the AgentRun row it is working on, and
11
- // processTurn created that run with the conversation id as its title, so
12
- // resolveTurn can map this call back to exactly one contact. Nothing the
13
- // model or the texter writes can change that mapping.
14
- async function currentTurn(ctx: ActionCtx): Promise<Turn> {
15
- const streamId = ctx.stream?.id;
16
- if (!streamId) throw new Error("This tool is only available while answering a text.");
17
- const turn = await ctx.runQuery<Turn | null>("resolveTurn", { streamId });
18
- if (!turn) throw new Error("This tool is only available while answering a text.");
19
- return turn;
20
- }
3
+ import { currentTurn } from "../lib/turn";
21
4
 
22
5
  // The assistant. Pylon's agent() runs the tool loop, persists every turn into
23
6
  // AgentRun/AgentMessage (live in the dashboard), and streams tokens.
24
7
  //
25
- // auth "admin": only the owner's session may run it. processTurn calls it as
26
- // the owner for each inbound text (lib/agent-call.ts); any other account gets
27
- // FORBIDDEN before the model is called.
8
+ // auth "admin": only the owner may run it. processTurn runs it as the owner
9
+ // for each inbound text (ctx.agents.run); any other account gets FORBIDDEN
10
+ // before the model is called.
28
11
  //
29
12
  // The tools act only on the contact of the current turn and never take a
30
13
  // contact, phone number, or id from the model.
@@ -40,23 +23,23 @@ export default agent({
40
23
  description:
41
24
  "Save a short fact about the person you are texting (a preference, a name, a date). One fact per call.",
42
25
  args: { text: v.string() },
43
- handler: async (ctx, input) => {
44
- const turn = await currentTurn(ctx);
26
+ handler: async (ctx, input, run) => {
27
+ const turn = await currentTurn(ctx, run);
45
28
  return ctx.runMutation("toolSaveNote", { contactId: turn.contactId, text: String(input.text) });
46
29
  },
47
30
  },
48
31
  recall_notes: {
49
32
  description: "List the facts saved about the person you are texting, newest first.",
50
- handler: async (ctx) => {
51
- const turn = await currentTurn(ctx);
33
+ handler: async (ctx, _input, run) => {
34
+ const turn = await currentTurn(ctx, run);
52
35
  return ctx.runQuery("toolListNotes", { contactId: turn.contactId });
53
36
  },
54
37
  },
55
38
  forget_note: {
56
39
  description: "Delete one saved fact about the person you are texting, by the id recall_notes returned.",
57
40
  args: { noteId: v.string() },
58
- handler: async (ctx, input) => {
59
- const turn = await currentTurn(ctx);
41
+ handler: async (ctx, input, run) => {
42
+ const turn = await currentTurn(ctx, run);
60
43
  return ctx.runMutation("toolForgetNote", { contactId: turn.contactId, noteId: String(input.noteId) });
61
44
  },
62
45
  },
@@ -64,8 +47,8 @@ export default agent({
64
47
  description:
65
48
  "Schedule a text to the person you are texting at a specific time. `when` is ISO 8601 with a UTC offset, e.g. 2026-10-03T09:00:00-05:00. `text` is the exact message they will receive.",
66
49
  args: { when: v.string(), text: v.string() },
67
- handler: async (ctx, input) => {
68
- const turn = await currentTurn(ctx);
50
+ handler: async (ctx, input, run) => {
51
+ const turn = await currentTurn(ctx, run);
69
52
  return ctx.runMutation("toolSetReminder", {
70
53
  contactId: turn.contactId,
71
54
  conversationId: turn.conversationId,
@@ -76,16 +59,16 @@ export default agent({
76
59
  },
77
60
  list_reminders: {
78
61
  description: "List the upcoming reminders for the person you are texting.",
79
- handler: async (ctx) => {
80
- const turn = await currentTurn(ctx);
62
+ handler: async (ctx, _input, run) => {
63
+ const turn = await currentTurn(ctx, run);
81
64
  return ctx.runQuery("toolListReminders", { contactId: turn.contactId });
82
65
  },
83
66
  },
84
67
  cancel_reminder: {
85
68
  description: "Cancel one upcoming reminder for the person you are texting, by the id list_reminders returned.",
86
69
  args: { reminderId: v.string() },
87
- handler: async (ctx, input) => {
88
- const turn = await currentTurn(ctx);
70
+ handler: async (ctx, input, run) => {
71
+ const turn = await currentTurn(ctx, run);
89
72
  return ctx.runMutation("toolCancelReminder", {
90
73
  contactId: turn.contactId,
91
74
  reminderId: String(input.reminderId),
@@ -1,5 +1,5 @@
1
1
  import { action, v } from "@pylonsync/functions";
2
- import { runAgentAsOwner } from "../lib/agent-call";
2
+ import { AGENT_NAME } from "../lib/assistant";
3
3
  import { chunkReply } from "../lib/chunk";
4
4
  import { providerStatus } from "../lib/provider";
5
5
 
@@ -47,27 +47,28 @@ export default action({
47
47
  return { state: "awaiting_setup" };
48
48
  }
49
49
 
50
- const outcome = await runAgentAsOwner(ctx.env, owner.userId, {
50
+ // The run belongs to the owner, so it shows up live in the dashboard.
51
+ // `admin: true` because the assistant is declared auth: "admin".
52
+ // The context tells the tools which conversation and turn they act on
53
+ // (functions/assistant.ts); only server code can set it.
54
+ const result = await ctx.agents.run(AGENT_NAME, {
51
55
  input: begin.input,
52
56
  ...(begin.runId ? { runId: begin.runId } : {}),
53
- // The run's title carries the conversation id. The agent's tools read
54
- // it back (functions/resolveTurn.ts) to know which contact they act on.
55
- title: begin.conversationId,
57
+ as: { userId: owner.userId, admin: true },
58
+ context: { conversationId: begin.conversationId, turnId: begin.turnId },
56
59
  });
57
- if (!outcome.ok) {
58
- await finish({ outcome: "failed", detail: `${outcome.code}: ${outcome.message}`.slice(0, 500) });
59
- return { state: "failed" };
60
- }
61
- const chunks = chunkReply(outcome.result.text);
60
+ const chunks = chunkReply(result.text);
62
61
  if (chunks.length === 0) {
63
- await finish({ outcome: "failed", detail: "The model returned an empty reply", runId: outcome.result.runId });
62
+ await finish({ outcome: "failed", detail: "The model returned an empty reply", runId: result.runId });
64
63
  return { state: "failed" };
65
64
  }
66
- await finish({ outcome: "answered", runId: outcome.result.runId, chunks });
65
+ await finish({ outcome: "answered", runId: result.runId, chunks });
67
66
  return { state: "answered", bubbles: chunks.length };
68
67
  } catch (err) {
68
+ const code = (err as { code?: unknown })?.code;
69
69
  const message = err instanceof Error ? err.message : String(err);
70
- await finish({ outcome: "failed", detail: message.slice(0, 500) });
70
+ const detail = typeof code === "string" ? `${code}: ${message}` : message;
71
+ await finish({ outcome: "failed", detail: detail.slice(0, 500) });
71
72
  return { state: "failed" };
72
73
  }
73
74
  },
@@ -1,25 +1,20 @@
1
1
  import { query, v } from "@pylonsync/functions";
2
- import { AGENT_NAME } from "../lib/assistant";
3
2
 
4
- // Map the agent's current stream id to the conversation it is answering.
5
- // Internal: called by the assistant's tools only (functions/assistant.ts).
3
+ // The contact a tool call acts on. Internal: called by the assistant's tools
4
+ // only (functions/assistant.ts), with the conversation and turn that
5
+ // processTurn put in the agent run's context.
6
6
  //
7
- // Returns null unless all of these hold:
8
- // - an AgentRun of the assistant carries this stream id and belongs to the caller
9
- // - its title is a conversation id (processTurn sets it)
10
- // - that conversation has a turn running right now
7
+ // Returns null unless that conversation exists and that exact turn is still
8
+ // running, so a tool cannot act for a finished turn (for example when the
9
+ // owner continues the run from the dashboard later).
11
10
  export default query({
12
11
  internal: true,
13
12
  auth: "admin",
14
- args: { streamId: v.string() },
13
+ args: { conversationId: v.string(), turnId: v.string() },
15
14
  async handler(ctx, args) {
16
- if (args.streamId === "") return null;
17
- const runs = await ctx.db.query("AgentRun", { streamId: args.streamId, $limit: 2 });
18
- if (runs.length !== 1) return null;
19
- const run = runs[0];
20
- if (run.agent !== AGENT_NAME || run.userId !== ctx.auth.userId || run.status !== "running") return null;
21
- const conversation = await ctx.db.get("Conversation", String(run.title ?? ""));
22
- if (!conversation || conversation.turnState !== "running") return null;
15
+ if (args.conversationId === "" || args.turnId === "") return null;
16
+ const conversation = await ctx.db.get("Conversation", args.conversationId);
17
+ if (!conversation || conversation.turnState !== "running" || conversation.turnId !== args.turnId) return null;
23
18
  return { conversationId: String(conversation.id), contactId: String(conversation.contactId) };
24
19
  },
25
20
  });