@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.
- package/package.json +1 -1
- package/templates/_root/AGENTS.md +1 -1
- package/templates/agency/AGENTS.md +1 -1
- package/templates/ai-chat/AGENTS.md +1 -1
- package/templates/ai-studio/AGENTS.md +1 -1
- package/templates/barebones/AGENTS.md +1 -1
- package/templates/chat/AGENTS.md +1 -1
- package/templates/chat/app/chat-client.tsx +1 -1
- package/templates/consumer/AGENTS.md +1 -1
- package/templates/consumer/components/social/post-parts.tsx +1 -1
- package/templates/consumer/components/social/post-screen.tsx +1 -1
- package/templates/consumer/components/social/use-social.ts +7 -7
- package/templates/creator/AGENTS.md +1 -1
- package/templates/crm/AGENTS.md +1 -1
- package/templates/directory/AGENTS.md +1 -1
- package/templates/helpdesk/AGENTS.md +1 -1
- package/templates/imessage/.env.example +2 -4
- package/templates/imessage/AGENTS.md +5 -4
- package/templates/imessage/README.md +14 -14
- package/templates/imessage/app/console.tsx +3 -3
- package/templates/imessage/components/setup-view.tsx +5 -9
- package/templates/imessage/functions/assistant.ts +17 -34
- package/templates/imessage/functions/processTurn.ts +14 -13
- package/templates/imessage/functions/resolveTurn.ts +10 -15
- package/templates/imessage/functions/setupStatus.ts +2 -3
- package/templates/imessage/lib/turn.ts +25 -0
- package/templates/imessage/lib/urls.ts +20 -0
- package/templates/imessage/tests/turn.test.ts +141 -0
- package/templates/inventory/AGENTS.md +1 -1
- package/templates/invoices/AGENTS.md +1 -1
- package/templates/invoices/app/invoices/[id]/invoice-view.tsx +2 -2
- package/templates/local-service/AGENTS.md +1 -1
- package/templates/marketplace/AGENTS.md +1 -1
- package/templates/marketplace/client/WatchButton.tsx +2 -2
- package/templates/projects/AGENTS.md +1 -1
- package/templates/projects/app/projects/[id]/project-view.tsx +2 -2
- package/templates/restaurant/AGENTS.md +1 -1
- package/templates/saas/AGENTS.md +1 -1
- package/templates/shop/AGENTS.md +1 -1
- package/templates/todo/AGENTS.md +1 -1
- package/templates/waitlist/AGENTS.md +1 -1
- package/templates/imessage/lib/agent-call.ts +0 -251
- 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.
|
|
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`.
|
|
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`.
|
|
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`.
|
|
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`.
|
|
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`.
|
|
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`.
|
package/templates/chat/AGENTS.md
CHANGED
|
@@ -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`.
|
|
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
|
-
|
|
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`.
|
|
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
|
-
|
|
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={() =>
|
|
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)
|
|
70
|
-
else if (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))
|
|
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)
|
|
78
|
-
else if (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)
|
|
84
|
-
else
|
|
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`.
|
|
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`.
|
package/templates/crm/AGENTS.md
CHANGED
|
@@ -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`.
|
|
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`.
|
|
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`.
|
|
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
|
|
46
|
-
#
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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`.
|
|
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 (
|
|
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
|
-
###
|
|
67
|
+
### How a turn runs the agent
|
|
68
68
|
|
|
69
|
-
`agent()` stores each run under
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
| `
|
|
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
|
|
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>
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 &&
|
|
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
|
-
`
|
|
204
|
-
status.devMode
|
|
205
|
-
? "
|
|
206
|
-
:
|
|
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
|
|
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
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
title: begin.conversationId,
|
|
57
|
+
as: { userId: owner.userId, admin: true },
|
|
58
|
+
context: { conversationId: begin.conversationId, turnId: begin.turnId },
|
|
56
59
|
});
|
|
57
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
5
|
-
//
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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: {
|
|
13
|
+
args: { conversationId: v.string(), turnId: v.string() },
|
|
15
14
|
async handler(ctx, args) {
|
|
16
|
-
if (args.
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
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
|
});
|