@agentshouse/skills 0.1.0-c221fe4e31976 → 0.1.0-c22b29c83d792

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.
@@ -9,9 +9,10 @@ object House hands the page as `window.houseBridge`, which `houseBridge()` of
9
9
  `@agentshouse/app-bridge` returns, draws it in `element` over the App's own client and
10
10
  resolves to a handle: `chat.update(options)` replaces its options and `chat.unmount()`
11
11
  removes it. It fills the element and lays itself out by the width it gives: the list of the
12
- User's Agents and their chats or one chat on a narrow element, the list beside the chat on a
13
- wider one once the User shows it or the element passes 1000 px, and the chat's details beside
14
- both on a wide one. With no chat open it shows what
12
+ User's Agents and their chats or one chat on a narrow element, and on a wider one the list
13
+ beside the chat while the User shows it by its toggle, closed at first beside an open chat and
14
+ open where none is, the conversation keeping its width where the element has room for both.
15
+ With no chat open it shows what
15
16
  asks the User and what works now. `conversation` opens one chat, and with it `null` the Chat
16
17
  opens a new chat with `agent`, the Agent it goes to, or the list with neither. `onOpen` hears
17
18
  each chat it opens as `{ conversation, agent }`, and both `null` when the User goes back to the
@@ -24,8 +25,9 @@ Chat in a sheet above the page, and pressing it again hides it. With `agent` amo
24
25
  the dot's first press opens it on a new chat with that Agent instead of a chat kept from an
25
26
  earlier visit, and later presses reopen the chat it last showed; the sheet stays hidden until
26
27
  the first press. The dot stays in view over the
27
- sheet, which the User moves, resizes up to the window's edges and lets fill the window, and the
28
- page under it never changes, so leave nothing of the page to make room for it.
28
+ sheet, which the User moves, resizes up to the window's edges and lets fill the window, and
29
+ whose list, closed at first, widens it to the left by the list's own width; the page under it
30
+ never changes, so leave nothing of the page to make room for it.
29
31
  `button.open(target)` opens the sheet, on `{ conversation }` or on a new chat with
30
32
  `{ agent }`, or as it was with no target, and with `docked: true` docked at full height along
31
33
  the window's right edge; `button.hide()` hides it, keeping its chat, scroll and typed text;
@@ -46,5 +48,5 @@ element, set them on the document's root element. Its dot is House's and takes n
46
48
  It shows conversations only where the release declares them;
47
49
  otherwise it lists no conversation and no Agent. A secret
48
50
  question is never answered here; typed answers live in the Presentation State members named
49
- `typed:…`, whether the list shows in `chat:page-list`, and the sheet's size, place, list and
50
- open chat in `chat:sheet`: leave them.
51
+ `typed:…`, and the sheet's size, place, whether it fills the window and its open chat in
52
+ `chat:sheet`: leave them.
@@ -2,16 +2,16 @@
2
2
 
3
3
  How an App reads and writes its Rooms on the device, offline too.
4
4
 
5
- - The runtime holds every bound Room on the device and streams in every change from any device, App or agent. `query` and `observe` answer from the device and never wait on the network; `search` asks House ([Search rules](apps/search)).
5
+ - The runtime holds every bound Room on the device and streams in every change from any device, App or agent, but a Search-level Room, which an App shows only as the rows [`queryHouse`](apps/query-house) asks for, never offline. `query` and `observe` answer from the device and never wait on the network; `search` asks House ([Search rules](apps/search)).
6
6
  - Draw from `observe` at once, with no request of your own; an observation hears each later change by itself, so never poll, refetch or subscribe. An answer is `{ pending }` while a Room it reads has not reached this device or been prepared on it: draw the page without those documents; keep the empty state for a complete answer. `rooms` answers once the runtime knows the Rooms the page reaches. Offer binding a Room when it is empty while the sync status's `providerUnreachable` is false and `observe('connection-refusal')` answers `null`; any other empty list says only that nothing can be listed yet.
7
7
  - Show no loading state: draw the last state the device holds. A wait shows only after an act the User pressed whose answer comes from outside the stream, such as `bridge.callExternal`, until it arrives.
8
8
  - Write optimistically: write a Room Draft, then `save`. The change shows in every query at once as a Pending Change and reaches House when it can. `save` refuses at once, with its `code`, a change that is `stale`, `changed-underneath` or against the schema: say so and keep the form. After `changed-underneath`, draw the joined Room Draft and its `marks` ([Room Drafts](apps/room-drafts)).
9
9
  - Show what the runtime reports: `observe('queue', room)` lists changes House has not confirmed yet, and `observe('sync-status')`'s `attentionRequired` lists each change House refused, returned as a Room Draft with why, to save again or discard.
10
- - Keep what the User types in the Room Draft (`roomDraft.write`) as they type; it survives a new release replacing the page, a reload and another tab. Draw a form again only when its page changes, never on a streamed change.
10
+ - Keep what the User types in the Room Draft (`roomDraft.write`) as they type; it survives a new release, a reload and another tab. Draw a form again only when its page changes, never on a streamed change.
11
11
  - A document has one Room Draft, shared by every tab and App, and `save` takes all of it. A write replaces every part and names the revision it read, so write onto the parts it holds; one from an older revision answers `stale` with the current Room Draft: show that one. A field part names as its `base` the value the page showed, `{ value }`, or `null` when the document has no such field.
12
12
  - Save a change the User does not type, such as a checkbox, with `saveParts(room, [{ path, parts }])`: it writes no Room Draft, so the one another tab holds stays.
13
13
  - Offline, all of this works and saves wait; `bridge.onStatus` reports `offline`: say so quietly.
14
- - A Room your User holds at the Open level locks once the browser has been offline 2 minutes, and from the moment it opens until House confirms the access. While it is locked, `rooms` lists it `locked: true`, a query that does not name it leaves it out, one naming it answers `{ pending }` with `awaits: 'unlock'`, and every other read and write naming it, `save` included, answers the code `room-locked` and keeps nothing. Draw it locked until it unlocks and keep what the User types in the page; what was saved before stays and is sent then.
15
- - An outside service is reached only at the origins `app.json` lists in `external_origins`, directly or through `bridge.callExternal`, sent by House's server. A call that names a Room bound to the App in a `write` slot with `room_ref` carries that Room's Service key for the origin, which the page never sees; any other call carries no key. A call through a Room that holds no key for the origin is refused `not_found` naming the `origin`: tell the person that the Room's owner or a manager enters the key under Connected on the Room's Settings page, since no App opens that page.
14
+ - A Room your User holds at the Open level locks once the browser has been offline 2 minutes, and from the moment it opens until House confirms the access. While it is locked, `rooms` lists it `locked: true`, a query that does not name it leaves it out, one naming it answers `{ pending }` with `awaits: 'unlock'`, and every other read and write naming it, `save` included, answers the code `room-locked` and keeps nothing. Draw it locked until then and keep what the User types; what was saved before is sent then.
15
+ - An outside service is reached only at the origins `app.json` lists in `external_origins`, directly or through `bridge.callExternal`, sent by House's server. A call whose `room_ref` names a Room bound in a `write` slot carries that Room's Service key for the origin, unseen by the page; any other carries none. Through a Room holding no key for the origin it is refused `not_found` naming the `origin`: tell the person its owner or a manager enters the key under Connected in the Room's Settings, which no App opens.
16
16
  - The page's own storage is a disposable cache, the Room the only durable home of the App's data; handle any code or value you do not know by its `code` ([Refusals](apps/runtime-refusals)).
17
17
  - React: `useObserve` and `useSave` from `@agentshouse/mdruntime/react`. The whole client: [The App runtime](apps/runtime).
@@ -0,0 +1,31 @@
1
+ # House query rules
2
+
3
+ How `queryHouse` asks House for pages of rows of a Room the device never holds.
4
+
5
+ A Room your User holds at the Search level never reaches the device. `rooms` lists it with
6
+ `queriesOnly: true` and nothing held, a [Local Query](apps/local-query) naming it fails with
7
+ the code `queries-only`, and one naming no Room leaves it out; its writes, history, files and
8
+ `resolve` answer as for a Room the App does not bind.
9
+
10
+ - **Asking.** `queryHouse(query)` takes a Document query in the language of a Local Query
11
+ whose `rooms` names that one Room, and asks House once, online. Nothing observes it: no
12
+ `observe`, hook or `readAgain` asks it again, so ask again when the page needs the rows
13
+ again, such as when the person scrolls, opens it again or presses refresh. `call` reaches no
14
+ `rooms/query`.
15
+ - **A page.** `{ kind: 'rows', documents, next }`: at most 100 rows, read in Path order,
16
+ ascending or with `orderBy: [{ by: 'path', direction: 'desc', stringSort: 'lexical' }]`.
17
+ Pass `next` as `after` for the next page. A page may hold fewer rows than `limit`, or none,
18
+ and still name `next`: only an answer without `next` is the end.
19
+ - **Refusals.** House answers `{ kind: 'rejected', code, fields }` for a part that would read
20
+ the whole Room, such as another order, `groupBy`, `total`, `hop`, `backlinks`, `fileIds`,
21
+ `select: 'all'` or a `limit` of 0, as `invalid-query` with a `message` naming the part, and
22
+ for a query over 8,000 bytes as `query_too_large` with its `cap`: fix the query.
23
+ - **Offline.** Without a connection `queryHouse` answers `{ kind: 'offline' }` at once: show
24
+ nothing of that Room and say it needs a connection. A lost answer is
25
+ `transport-uncertainty`, which the page may ask again, and where no House query is reached,
26
+ `{ kind: 'undeclared' }`.
27
+ - **Other Rooms.** A Room the device holds answers `rejected` with the code
28
+ `held-on-device`: read it with a Local Query. A Room the App does not reach answers
29
+ `room-not-readable`.
30
+ - **Nothing kept.** The rows reach the page alone: the device keeps none of them, so they are
31
+ gone once the page closes, and asking again shows what the Room holds then.
@@ -3,7 +3,7 @@
3
3
  What App code reaches through the runtime's client: its surfaces, its reach, observations, Agents and conversations.
4
4
 
5
5
  App code reaches the MD Runtime through the remote client. The surfaces are Rooms, Query,
6
- Search, Observe, Types, Room Draft, Save, History, Resolve, binary files, Call,
6
+ Search, House query, Observe, Types, Room Draft, Save, History, Resolve, binary files, Call,
7
7
  Reached, the reported persistence answer, reauthorization, conversation, the chat
8
8
  draft, and Presentation State. Sync Status, readiness, the Service Mode, the storage-loss
9
9
  statement, the deployment facts, the signed-in member and the count of authority changes
@@ -11,7 +11,8 @@ are observations only.
11
11
 
12
12
  The host attaches each port with its reach, and every member and observation answers
13
13
  within it: a Room outside it answers as one the replica does not hold, Rooms lists only
14
- the Rooms reached whole, a Room reached for single Documents shows only them and their
14
+ the Rooms reached whole and, after them with `queriesOnly: true`, those its User holds at the
15
+ Search level, which only `queryHouse` reads ([House query rules](apps/query-house)), a Room reached for single Documents shows only them and their
15
16
  models' Types, and without conversations the conversation members, chat draft and
16
17
  observation hold none. A changed reach reaches every open observation as its new value.
17
18
 
@@ -8,6 +8,7 @@ change it, build, and push. They are documentation, not enforcement.
8
8
  - [The MD Runtime API](apps/runtime-api)
9
9
  - [Local Query rules](apps/local-query)
10
10
  - [Search rules](apps/search)
11
+ - [House query rules](apps/query-house)
11
12
  - [Room Draft, Save, and outcome rules](apps/room-drafts)
12
13
  - [Content safety and launch promises](apps/content-safety)
13
14
  - [App code takes the client its host hands it and never constructs a Runtime](apps/client)
@@ -12,8 +12,10 @@ Build it as [Build an App](apps/build) says, with what follows. Anything private
12
12
  - Everything it shows lives as documents in its Room; put its demo data there, never in code.
13
13
  - A visitor's edits stay in their browser and never reach House: write them with `save` as an App does.
14
14
  - To receive messages from visitors, name an Intake Room with `sites/intake-room/set` and send each with `runtime.call('sites/submissions/create', { content })`, online only. `content` is one Markdown document the Intake Room's `settings/schema.yaml` accepts, and House adds its `site` and `submitted` fields. The call answers `{ kind: 'action', result: { submitted: true } }` once House stored it. Anything else stored nothing: `{ kind: 'rejected', code }` says why, such as `bot_check_failed` for a browser that failed the bot check, as automated test browsers do, `site_intake_room_absent`, `invalid_documents` with the `path`, `rule` and `field` it broke, `submission_reserved_key`, `submission_too_large`, `limit_reached` or `cap_reached`. So thank the visitor only for `submitted`, and otherwise show that nothing was sent.
15
+ - A Site loads House's servers when it is bound to a Room that changes often, or when it writes once per visitor, such as a Submission sent on every visit: send one only when a visitor asks to.
16
+ - Code that holds a key, such as one opening a checkout, runs in one of the App's functions, which the page calls: [A Site's function calls](sites/functions). [A Site's payments](sites/payments) takes payments with them.
15
17
  - `sites/describe` sets the title and description search engines show.
16
18
  - With `google_analytics_id` set by `sites/describe`, House measures a visitor only after they accept. While `bridge.onStatus` reports `analytics.measures`, ask them on the Site unless `analytics.choice` holds their answer, report it with `bridge.analyticsConsent('accept')` or `bridge.analyticsConsent('decline')`, and keep a way on every page to decline later, which reloads the page unmeasured.
17
19
  - See it yourself: open its url, which needs no sign-in.
18
20
 
19
- House serves each route as HTML by the [Entry Data Contract](sites/entry-data-contract), and [The App runtime on a Site](sites/runtime) says what the runtime does differently there.
21
+ Build its first screen as [A Site's first load](sites/first-load) says. House serves a route as HTML only where the [Entry Data Contract](sites/entry-data-contract) declares it, and [The App runtime on a Site](sites/runtime) says what the runtime does differently there.
@@ -1,12 +1,20 @@
1
1
  # Entry Data Contract
2
2
 
3
- How a Site App's `entry_data` has House serve each route as HTML from Rooms.
3
+ How a Site App's `entry_data` has House serve each route as HTML before the App starts.
4
4
 
5
- Entry Data Contract, `entry_data` in a Site App’s `app.json`: House serves each route as HTML from your Rooms before the App starts, running none of your code. Each of `routes` has a `path` (`/`, `/posts/:slug`), optional `query` (functional `?name` parameters with defaults), `queries` (Local Query objects with `types` and a `pathPrefix` at each scope, no `rooms` or `fileIds`; `{"$param": "slug"}` takes an argument, `"as": "integer"` converts it; `boundQuery` from `@agentshouse/mdruntime/entry` fills the same query in your page), `interaction` (the queries its first interaction needs), `title`, `description` and `body`. A body node is text, `{text: v}`, `{markdown: v}` of a document, `{tag, attrs, children}`, `{each: v, as, children}` or `{if: v, then, else}`; a value `v` is a literal, `{param}`, `{at, pointer}` into a query result or `each` item, `{join: [v]}`, `{count: v}` or `{stem: v}`. An `img`, a document's images among them, loads lazily in the box its `width` and `height` name, 16:9 without them. `styles` lists release CSS files. A route that fails starts the App as usual, and the Site document names why.
5
+ Entry Data Contract, `entry_data` in a Site App’s `app.json`: House serves each route in its `routes` list as HTML before the App starts, running none of your code. A Site has an Entry Page only for a route you list; any other address starts the App as usual.
6
+
7
+ A static route, one with no `queries` and no parameter, names only its `path`. Its Entry Page is your release's own HTML for that path: the `entry` document for `/`, `about/index.html` for `/about`. Write the route's first screen into that HTML. The push reads it without running it: it keeps the body's text and its plain elements (headings, paragraphs, lists, tables, links, images and the like), drops scripts, `on…` attributes and every other element (`button`, `form`, `svg`, `picture` among them) with its content, resolves relative addresses, drops an image the release lacks, and takes the head's `<title>`, `<meta name="description">` and each release stylesheet it links for every screen (no `media` but `all` or `screen`, not `alternate`). A static route that declares `body`, `title`, `description` or `styles`, whose HTML the release lacks, that links more than 8 stylesheets, or whose body passes 2000 nodes, 24 levels or, with every other route, 64 kB, refuses the push.
8
+
9
+ A route that reads Rooms or takes a parameter declares its Entry Page: a `path` (`/`, `/posts/:slug`), optional `query` (functional `?name` parameters with defaults), `queries` (Local Query objects with `types` and a `pathPrefix` at each scope, no `rooms` or `fileIds`; `{"$param": "slug"}` takes an argument, `"as": "integer"` converts it; `boundQuery` from `@agentshouse/mdruntime/entry` fills the same query in your page), `interaction` (the queries its first interaction needs), `title`, `description`, `styles` (release CSS files) and `body`. A body node is text, `{text: v}`, `{markdown: v}` of a document, `{tag, attrs, children}`, `{each: v, as, children}` or `{if: v, then, else}`; a value `v` is a literal, `{param}`, `{at, pointer}` into a query result or `each` item, `{join: [v]}`, `{count: v}` or `{stem: v}`.
10
+
11
+ On every Entry Page an `img` keeps the `loading` and `fetchpriority` it names and loads lazily where it names no `loading`; a document's images always load lazily. Each takes the box its `width` and `height` name, 16:9 without them, and an `img` whose `src` is relative (`images/hero.webp`) shows that file of your release. House puts each stylesheet into the page itself, its relative `url()`s pointing into your release, so the page draws in your fonts and styles from its first byte. A route that fails starts the App as usual, and the Site document names why.
6
12
 
7
13
  ```json
8
14
  {
9
15
  "routes": [
16
+ { "path": "/" },
17
+ { "path": "/about" },
10
18
  {
11
19
  "path": "/posts/:slug",
12
20
  "queries": {
@@ -18,6 +26,7 @@ Entry Data Contract, `entry_data` in a Site App’s `app.json`: House serves eac
18
26
  }
19
27
  },
20
28
  "title": { "at": "post", "pointer": "/documents/0/fields/title" },
29
+ "styles": ["assets/site.css"],
21
30
  "body": [{ "markdown": { "at": "post", "pointer": "/documents/0" } }]
22
31
  }
23
32
  ]
@@ -0,0 +1,15 @@
1
+ # A Site's first load
2
+
3
+ How to build a Site whose first screen reaches a visitor's phone within about a second, and how to see that it does.
4
+
5
+ A phone on a slow link pays for every byte before the first screen, so what the release puts first decides how soon it shows. Build every Site this way by default:
6
+
7
+ - **An Entry Page for every entry route.** House draws a route as HTML before the App starts only where the release declares it in `entry_data` ([Entry Data Contract](sites/entry-data-contract)); any other route shows once the App has run. A route with no queries and no parameter names only its `path`, and its Entry Page is the release's own HTML for that path: the entry document for `/`, and `about/index.html` in the release for `/about`. House reads that HTML and never runs it, keeping its text and plain elements and taking its title, description and stylesheets from its head, so prerender each such route's first screen into it at build, its main image a plain `img`, since a `picture`, an `svg` and a script are dropped. A route that reads Rooms or takes a parameter declares its queries and its body. An Entry Page draws in the Site's own fonts, styles and images, so nothing moves when the App takes over.
8
+ - **Images at the size the App draws them**, each with its `width` and `height`: a larger file costs bytes nobody sees, and the box stays still while it loads.
9
+ - **The first image at once**: the first screen's main image names `loading="eager"` and `fetchpriority="high"`, in the release's HTML and in the App, since an Entry image that names no `loading` loads lazily. Every image below the first screen loads lazily.
10
+ - **No entrance animation on the first screen**: draw it in its final place, since content that fades or slides in counts as shown only once it stops.
11
+ - **Heavy libraries after the first draw**, behind a poster: load a 3D scene, a map or a video player with `import()` once the first screen has drawn, and show a still image of it until it runs.
12
+ - **Built minified and split**: minification on, and each route and heavy part a chunk of its own loaded by `import()`, so the first screen loads only its own code.
13
+ - **At most two font families**, the Site's own, their files in the release.
14
+ - **The first view within the bound** the publish warns at. The first view is the entry document, its scripts and stylesheets and what they import statically, compressed as House stores them. A first view past the bound, or no Entry Page for `/`, never stops a publish: its answer and `sites/list` name each in `warnings` by its `code`, `first_view_heavy` with its `bytes` and `bound`, and `entry_page_missing`. Fix them before the next publish.
15
+ - **Read `first_load` in `sites/list` after each publish.** House loads the Site once more, cold, as a phone on a slow link, and answers `{ first_content_ms, largest_content_ms, at }` for the release or publication it serves: `null` before that figure, when that load failed or where the Building times no Site. A figure that grew points at what that release added.
@@ -0,0 +1,13 @@
1
+ # A Site's function calls
2
+
3
+ How a Site's page calls a function its App declares and gets its answer, so code that holds a key, such as one opening a checkout, runs on House's servers and never on the page.
4
+
5
+ - **The call.** `runtime.call('sites/functions/call', { function, input })`, online only, calls any function the App's latest release declares ([App functions](apps/functions)), with `input` any JSON up to <function-input-size>, `null` when left out. It answers `{ kind: 'action', result: { answer } }`, `answer` being the function's return value as JSON, `null` when it returns nothing. Offline it answers `transport-uncertainty` at once and sends nothing later, and a Site House does not serve answers it too.
6
+ - **Open to every visitor.** Anyone at the Site calls any declared function with any input, signed in or not, and no bot check stands before it: check `call.kind` and `call.input` in every function, and look up a price, total or order by its id rather than taking it from the input.
7
+ - **What a function receives.** `export default async (call, house) => {}` receives `{ kind: 'site', id, site, moment, input, rooms, intake_room }`. `kind` is `site`, which no Trigger's event carries, so a function written for a Trigger refuses a visitor; `id` is new for each call; `site` is the Site's address; `moment` is when the call started, which `Date` reads; `input` is the call's input, whole; `rooms` lists under each slot of the release the Room references the Site binds there; and `intake_room` is the Intake Room's reference or `null`.
8
+ - **Its Rooms.** A call reads and writes every Room the Site binds and its Intake Room, with the Site owner's authority and those Rooms' Service keys, and no other Room. It has no Room of its own, so each method names one: `query` one Room in `rooms`, `saveParts(room, parts)` its first argument, `call` and `callExternal` their `room_ref`, and `toMoment` and `toLocal` their `room_ref` ([Times](apps/function-times)). Its writes are the App's, by `App <App> (function <function>)`, and count against the owner's Write limit.
9
+ - **Refused before it runs.** `{ kind: 'rejected', code }` with `not_found` for a function the latest release does not declare, `function_input_too_large`, or `limit_reached` with `limit: 'site_function_calls'` and its `retry_at` once the Site's calls pass their rate, which Full House raises.
10
+ - **Refused after it was taken.** A call runs in its Site owner's run slots, which their Triggers' runs share, at most <function-owner-slots> at once on each server. `function_not_started` says it found no slot within <function-start-wait> and never ran: send it again. `function_failed` with `error` says it ran and failed: its thrown text, `time_cap_reached` at <function-time>, `memory_cap_reached`, `external_call_cap_reached`, `answer_not_json` for an answer JSON cannot hold, or `answer_too_large` past <function-answer-size>. `function_unanswered` says House lost the answer after it may have started, so it may have run: a function that charges or orders names what it writes by an id the page sends, so a second call finds it.
11
+ - **Nothing kept.** House keeps nothing of a call once it has answered but its figure among the App's function runs.
12
+
13
+ Next: [A Site's payments](sites/payments)
@@ -0,0 +1,78 @@
1
+ # Payments with Stripe: checkout
2
+
3
+ The `checkout` function, which opens one Stripe Checkout Session for each order and answers `{ url }`, its page.
4
+
5
+ `functions/checkout.js`:
6
+
7
+ ```js
8
+ const STRIPE = 'https://api.stripe.com';
9
+ const CURRENCY = 'usd';
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
11
+ const KEY = /^[a-z0-9-]{1,64}$/;
12
+
13
+ const form = (fields) =>
14
+ Object.entries(fields)
15
+ .map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`)
16
+ .join('&');
17
+
18
+ export default async (call, house) => {
19
+ if (call.kind !== 'site') throw new Error('checkout serves a Site');
20
+ const { order, cart } = call.input ?? {};
21
+ if (typeof order !== 'string' || !UUID.test(order)) throw new Error('the order is no UUID');
22
+ const orders = call.intake_room;
23
+ const path = `orders/${order}.md`;
24
+ const read = async (room, query) =>
25
+ (await house.query({ rooms: [room], select: { fields: 'all' }, ...query })).documents;
26
+ const [held] = await read(orders, { types: ['order'], paths: [path] });
27
+ if (held !== undefined) return { url: held.fields.checkout_url };
28
+ if (!Array.isArray(cart) || cart.length === 0 || cart.length > 20)
29
+ throw new Error('the cart holds 1 to 20 lines');
30
+ const wanted = cart.map((line) => {
31
+ const { product, quantity } = line ?? {};
32
+ if (!KEY.test(String(product)) || !Number.isInteger(quantity) || quantity < 1 || quantity > 99)
33
+ throw new Error('a line names no product and count');
34
+ return { path: `products/${product}.md`, quantity };
35
+ });
36
+ const products = await read(call.rooms.products[0], {
37
+ types: ['product'],
38
+ paths: wanted.map((line) => line.path),
39
+ });
40
+ const lines = wanted.map(({ path: at, quantity }) => {
41
+ const product = products.find((found) => found.path === at);
42
+ if (product === undefined) throw new Error(`the shop sells nothing at ${at}`);
43
+ return { name: product.fields.name, price: product.fields.price, quantity };
44
+ });
45
+ const session = {
46
+ mode: 'payment',
47
+ client_reference_id: order,
48
+ success_url: `${call.site}/paid/${order}`,
49
+ cancel_url: `${call.site}/`,
50
+ };
51
+ lines.forEach((line, at) => {
52
+ session[`line_items[${at}][quantity]`] = line.quantity;
53
+ session[`line_items[${at}][price_data][currency]`] = CURRENCY;
54
+ session[`line_items[${at}][price_data][unit_amount]`] = line.price;
55
+ session[`line_items[${at}][price_data][product_data][name]`] = line.name;
56
+ });
57
+ const answer = await house.callExternal({
58
+ url: `${STRIPE}/v1/checkout/sessions`,
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
61
+ body: form(session),
62
+ room_ref: orders,
63
+ });
64
+ if (answer.status !== 200) throw new Error(`Stripe answered ${answer.status}`);
65
+ const { url } = JSON.parse(answer.body);
66
+ const items = lines.map((line) => `${line.quantity} × ${line.name}`).join(', ');
67
+ const total = lines.reduce((sum, line) => sum + line.price * line.quantity, 0);
68
+ const fields = { state: 'pending', items, total, checkout_url: url };
69
+ const creation = { type: 'order', fields, sections: {} };
70
+ const saved = await house.saveParts(orders, [{ path, parts: { creation } }]);
71
+ if (saved.outcome !== 'saved') throw new Error(`the order was not saved: ${saved.code}`);
72
+ return { url };
73
+ };
74
+ ```
75
+
76
+ A call for an order that exists answers its address. Otherwise `checkout` opens a session and writes the order with its address only after, so every order holds the checkout that charges what it says. A call that Stripe refused, or whose order was not written, leaves no order and answers no address, and the visitor's next call prices the cart again and opens a session of its own. The call sends no `Idempotency-Key`: a session no order holds is never shown and lapses unpaid, and a key would hold a repriced retry to its first lines. A visitor sees Stripe's refusal as its status alone. The secret key goes with the call because it names the Intake Room.
77
+
78
+ Next: [Payments with Stripe: paid and order](sites/payments-stripe-3)
@@ -0,0 +1,67 @@
1
+ # Payments with Stripe: paid and order
2
+
3
+ The `paid` function, which marks an order paid once Stripe's signed webhook says so, and the `order` function, which answers an order's state.
4
+
5
+ `functions/paid.js`, run by the Trigger on each delivery. Stripe signs `t`, a dot and the body with the endpoint's secret, and its `Stripe-Signature` header carries `t` and one `v1` signature or more:
6
+
7
+ ```js
8
+ const STRIPE = 'https://api.stripe.com';
9
+ const TOLERANCE_SECONDS = 300;
10
+
11
+ export default async (event, house) => {
12
+ if (event.kind !== 'items') throw new Error('paid runs on a delivery');
13
+ const header = [event.item.headers['stripe-signature'] ?? ''].flat().join(',');
14
+ const pairs = header.split(',').map((pair) => pair.split('='));
15
+ const [, stamp = ''] = pairs.find(([name]) => name === 't') ?? [];
16
+ if (!/^\d+$/.test(stamp) || Math.abs(Date.now() / 1000 - Number(stamp)) > TOLERANCE_SECONDS)
17
+ throw new Error('the delivery was not signed in the last five minutes');
18
+ const text = `${stamp}.${event.item.body}`;
19
+ let matched = false;
20
+ for (const [name, signature] of pairs) {
21
+ if (name !== 'v1' || matched) continue;
22
+ const checked = await house.call('service-keys/signatures/check', {
23
+ room_ref: event.room,
24
+ origin: STRIPE,
25
+ text,
26
+ signature,
27
+ });
28
+ if (checked.kind !== 'action') throw new Error(`the check was refused: ${checked.code}`);
29
+ matched = checked.result.matched;
30
+ }
31
+ if (!matched) throw new Error('the signature does not match');
32
+ const session = JSON.parse(event.item.body).data.object;
33
+ if (session.payment_status !== 'paid') return;
34
+ const path = `orders/${session.client_reference_id}.md`;
35
+ const query = { types: ['order'], paths: [path], select: { fields: ['/state'] } };
36
+ const [order] = (await house.query(query)).documents;
37
+ if (order === undefined) throw new Error(`no order at ${path}`);
38
+ if (order.fields.state === 'paid') return;
39
+ const fields = { state: { value: 'paid', base: { value: 'pending' } } };
40
+ const email = session.customer_details?.email;
41
+ if (typeof email === 'string') fields.email = { value: email, base: null };
42
+ const saved = await house.saveParts(event.room, [{ path, parts: { fields } }]);
43
+ if (saved.outcome !== 'saved') throw new Error(`the order was not saved: ${saved.code}`);
44
+ };
45
+ ```
46
+
47
+ `Date` reads the moment House stored the delivery, and a `base` of `null` sets a field the order lacks. A session paid by a method that settles later arrives `unpaid` with `checkout.session.completed` and marks nothing until `checkout.session.async_payment_succeeded`.
48
+
49
+ `functions/order.js`, which answers `pending`, `paid`, or `null` for an id that names no order:
50
+
51
+ ```js
52
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
53
+
54
+ export default async (call, house) => {
55
+ const order = call.input?.order;
56
+ if (call.kind !== 'site' || typeof order !== 'string' || !UUID.test(order)) return null;
57
+ const { documents } = await house.query({
58
+ rooms: [call.intake_room],
59
+ types: ['order'],
60
+ paths: [`orders/${order}.md`],
61
+ select: { fields: ['/state'] },
62
+ });
63
+ return documents[0]?.fields.state ?? null;
64
+ };
65
+ ```
66
+
67
+ Next: [Payments with Stripe: the Site's page](sites/payments-stripe-4)
@@ -0,0 +1,96 @@
1
+ # Payments with Stripe: the Site's page
2
+
3
+ The Site's page, which sells the products, calls `checkout` and opens Stripe's page, and at `/paid/<order>`, where Stripe sends the buyer back, calls `order`.
4
+
5
+ `index.html`:
6
+
7
+ ```html
8
+ <!doctype html>
9
+ <html lang="en">
10
+ <title>Shop</title>
11
+ <main></main>
12
+ <script type="module" src="main.js"></script>
13
+ </html>
14
+ ```
15
+
16
+ `src/main.ts`, which `npm run build` bundles into `main.js` as [Start an App](apps/start) says:
17
+
18
+ ```ts
19
+ import { houseBridge, type HouseParams } from '@agentshouse/app-bridge';
20
+
21
+ const bridge = houseBridge();
22
+ const runtime = await bridge.runtime;
23
+ const main = document.querySelector('main')!;
24
+ const cart: Record<string, number> = {};
25
+ let order: string | null = null;
26
+
27
+ async function called(
28
+ name: string,
29
+ input: HouseParams<'sites/functions/call'>['input'],
30
+ ): Promise<unknown> {
31
+ const answered = await runtime.call('sites/functions/call', { function: name, input });
32
+ if (answered.kind !== 'action') throw new Error(answered.code);
33
+ return answered.result.answer;
34
+ }
35
+
36
+ async function shop(): Promise<void> {
37
+ const products = await runtime.observe('query', {
38
+ types: ['product'],
39
+ select: { fields: ['/name', '/price'] },
40
+ orderBy: [{ by: { field: '/name' } }],
41
+ });
42
+ const pay = Object.assign(document.createElement('a'), { textContent: 'Pay with Stripe' });
43
+ const note = document.createElement('p');
44
+ const button = Object.assign(document.createElement('button'), { textContent: 'Check out' });
45
+ pay.hidden = true;
46
+ button.onclick = async () => {
47
+ order ??= crypto.randomUUID();
48
+ const lines = Object.entries(cart).filter(([, quantity]) => quantity > 0);
49
+ try {
50
+ const answer = await called('checkout', {
51
+ order,
52
+ cart: lines.map(([product, quantity]) => ({ product, quantity })),
53
+ });
54
+ pay.href = (answer as { url: string }).url;
55
+ pay.hidden = false;
56
+ note.textContent = '';
57
+ } catch (error) {
58
+ note.textContent = `Not opened: ${(error as Error).message}.`;
59
+ }
60
+ };
61
+ const draw = (answer: ReturnType<typeof products.current>) => {
62
+ if ('pending' in answer) return;
63
+ const rows = answer.documents.map((product) => {
64
+ const key = product.path.slice('products/'.length, -'.md'.length);
65
+ const price = (Number(product.fields!.price) / 100).toFixed(2);
66
+ const count = Object.assign(document.createElement('input'), {
67
+ type: 'number',
68
+ min: '0',
69
+ max: '99',
70
+ value: String(cart[key] ?? 0),
71
+ });
72
+ count.onchange = () => {
73
+ cart[key] = Number(count.value);
74
+ order = null;
75
+ pay.hidden = true;
76
+ };
77
+ const row = document.createElement('label');
78
+ row.append(`${String(product.fields!.name)}, $${price} `, count);
79
+ return row;
80
+ });
81
+ main.replaceChildren(...rows, button, pay, note);
82
+ };
83
+ draw(products.current());
84
+ products.subscribe(draw);
85
+ }
86
+
87
+ async function paid(id: string): Promise<void> {
88
+ const state = await called('order', { order: id }).catch(() => null);
89
+ main.replaceChildren(state === 'paid' ? 'Paid, thank you.' : 'Not paid yet: reload soon.');
90
+ }
91
+
92
+ const back = /^\/paid\/([0-9a-f-]{36})$/.exec(bridge.address() ?? '');
93
+ await (back === null ? shop() : paid(back[1]!));
94
+ ```
95
+
96
+ A changed cart draws a new order, since an order's checkout holds the lines it was opened with. Stripe's page is another origin, so its link opens in a new tab.
@@ -0,0 +1,63 @@
1
+ # Payments with Stripe
2
+
3
+ How a Site takes card payments with Stripe end to end: the App, its two Rooms, the keys, the webhook and the Trigger here, and the code over the next pages.
4
+
5
+ It builds [A Site's payments](sites/payments). Build and try it in a Stripe sandbox, whose secret key starts `sk_test_` and whose test cards, such as `4242 4242 4242 4242`, charge nothing.
6
+
7
+ `schema.yaml`, also written into both Rooms' `settings/schema.yaml`; a `price` and a `total` are in cents:
8
+
9
+ ```yaml
10
+ product:
11
+ properties:
12
+ name: { type: string }
13
+ price: { type: integer }
14
+ required: [name, price]
15
+ order:
16
+ properties:
17
+ state: { type: string, enum: [pending, paid] }
18
+ items: { type: string }
19
+ total: { type: integer }
20
+ checkout_url: { type: string }
21
+ email: { type: string }
22
+ required: [state, items, total]
23
+ ```
24
+
25
+ `app.json`:
26
+
27
+ ```json
28
+ {
29
+ "entry": "index.html",
30
+ "external_origins": ["https://api.stripe.com"],
31
+ "room_slots": [
32
+ { "name": "products", "access": "read", "types": ["product"] },
33
+ { "name": "orders", "access": "write", "types": ["order"] }
34
+ ],
35
+ "functions": [
36
+ { "name": "checkout", "entry": "functions/checkout.js" },
37
+ { "name": "paid", "entry": "functions/paid.js" },
38
+ { "name": "order", "entry": "functions/order.js" }
39
+ ]
40
+ }
41
+ ```
42
+
43
+ - **Products.** One Room holds each `product` at `products/<key>.md`, its `# Name` line over a `- **price**: 1200` line. The Site binds it in `products` with `sites/bindings/set` and shows it with `sites/publish`.
44
+ - **Orders.** Another Room is the Site's Intake Room (`sites/intake-room/set`), bound to the App in `orders` with `apps/bindings/set`. The Site binds no Room in `orders`.
45
+ - **The keys.** Your User enters two keys in the Intake Room for `https://api.stripe.com`: Stripe's secret key as `bearer`, and the webhook endpoint's signing secret, `whsec_` and the rest, as `signing`.
46
+ - **The webhook.** `capture/push-addresses/create` in the Intake Room with `event_id` `{"json": "/id"}`, where Stripe carries its event id. In Stripe your User adds a webhook endpoint at the push address's `address` that sends `checkout.session.completed` and `checkout.session.async_payment_succeeded`, then copies its signing secret into the key above.
47
+ - **The Trigger.** `triggers/create` in the Intake Room with one action, `{"kind": "function", "app": "<App>", "function": "paid", "enabled": true}`, and this `condition`:
48
+
49
+ ```json
50
+ {
51
+ "on": "items",
52
+ "query": {
53
+ "where": {
54
+ "/address": "<push address>",
55
+ "/json/type": {
56
+ "$in": ["checkout.session.completed", "checkout.session.async_payment_succeeded"]
57
+ }
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ Next: [Payments with Stripe: checkout](sites/payments-stripe-2)
@@ -0,0 +1,13 @@
1
+ # A Site's payments
2
+
3
+ How a Site takes payments into its owner's own account at a payment provider, with House never holding the money.
4
+
5
+ House has no payments of its own: a Site's App takes them with [its function calls](sites/functions), a Room's Service keys, a push address and a Trigger, the same way for any provider that fits. [Payments with Stripe](sites/payments-stripe) does it end to end.
6
+
7
+ - **Where things live.** The Site's Published Rooms hold what it sells and its prices. Its Intake Room holds its orders and buyers, the provider's keys, the push address and its Trigger: no visitor reads it, and a Site's call reaches it. Bind it to the App in a write slot with `apps/bindings/set`, so the Trigger runs the App's function there and the App shows its owner the orders at its App address; the Site binds no Room in that slot.
8
+ - **The keys.** Your User enters both in the Intake Room on the House page for the provider's API origin, which the release's `external_origins` lists: the API key in the form the provider takes it, such as `bearer`, and the webhook's signing secret as `signing`. A function sends the first with `callExternal` and checks a delivery against the second with `service-keys/signatures/check`, and never sees either.
9
+ - **One checkout per order.** The page draws the order's id with `crypto.randomUUID()`, keeps it, and calls `checkout` with it and the cart. `checkout` refuses an id that is no UUID and answers the checkout's address for an order that exists. For one that does not, it prices the cart from the Published Rooms and never from the input, opens the provider's checkout with the order's id as its reference for the order, then writes the order `pending` with that address and answers the address once the order is written. So each order holds the checkout that charges what it says, and a checkout that failed leaves no order, so the next call prices the cart again and opens a checkout of its own. The order's id is never the provider's idempotency key, which would hold that call to the first call's lines. The page opens the address.
10
+ - **Paid.** The provider's webhook reaches the Intake Room's push address (`capture/push-addresses/create`), whose `event_id` names where the provider carries its event id, so a resent delivery writes nothing, and whose `reply`, `{ body, type }`, names a fixed answer every `200` carries, for a provider that resends until it reads its own answer. A Trigger on that address's items runs `paid`, which checks the delivery's signature and throws when it does not match, so the run fails and the owner sees it in `triggers/fired/list`. When it matches, `paid` marks the order paid, and a delivery for a paid order changes nothing.
11
+ - **Read.** The page the provider sends the buyer back to calls `order` with the id it kept, and `order` answers only the order's state, since the id is the buyer's only key to it.
12
+ - **Which provider fits.** One whose API takes a key in a form the House page offers and whose webhook is a POST it counts delivered on any `2xx`, which the push address answers once it stored the delivery, with its `reply` when the provider waits for a fixed answer of its own. One that signs its webhook with HMAC-SHA256 is checked as above. One that signs nothing is checked by reading the payment back through its API with the key, and its `paid` refuses a payment that read does not show paid. One whose API needs each request signed, or whose webhook waits for an answer that depends on the delivery or is signed, cannot be used.
13
+ - **The money.** It goes from the buyer to the owner's account at the provider. House holds, charges and settles none of it, and refunds, payouts, receipts and taxes stay between the owner and the provider.
@@ -1,6 +1,6 @@
1
1
  # The App runtime on a Site
2
2
 
3
- What the App runtime does differently on a Site's page. Everything else is as in an App, [Local first](apps/local-first) included: House serves a Site's first screen as HTML from its [Entry Data Contract](sites/entry-data-contract) before the App starts, so a Site's page draws no loading state either.
3
+ What the App runtime does differently on a Site's page. Everything else is as in an App, [Local first](apps/local-first) included, so a Site's page draws no loading state either. A route has an Entry Page, House's HTML of it before the App starts, only where the release declares one in its [Entry Data Contract](sites/entry-data-contract).
4
4
 
5
5
  A History revision or a Document's `change` written by a Site gives its `author` as `{ kind: 'site', id, name }`. `observe('member')` answers `null` on a Site, and a Document's `change` is `null` there, since the Provider delivers no time.
6
6
 
@@ -9,7 +9,7 @@ and its Pending Change shows in every query and across reloads until the browser
9
9
  cleared. It is never sent: `observe('queue', room)` shows it `local`, Sync Status never waits on
10
10
  it, and once the Room no longer lets it apply it leaves the queue and returns no Room Draft.
11
11
 
12
- A Site's page fills its [Entry Data Contract](sites/entry-data-contract)'s
12
+ Where House served a route's Entry Page, the page fills that route's
13
13
  `{"$param": name}` arguments with `boundQuery` from `@agentshouse/mdruntime/entry`, as
14
14
  House did for its HTML. While the Site prepares, a query a route's `interaction` names
15
15
  answers from the entry's result when the page observes that descriptor, and every other
@@ -11,9 +11,20 @@ Build the App a prototype made, once the User has decided on its design, and pub
11
11
 
12
12
  Read the map `prototype-app` opened for this App. Start only when its design-decision ticket is resolved with the User's decision. Until then do nothing else: tell the User their decision on the prototype's design is missing, give them the prototype's address, and stop. Without such a map there is no decision: say so and offer [`prototype-app`](skills/prototype-app).
13
13
 
14
+ ## Design
15
+
16
+ Claim the build ticket. Read [Design an App](apps/design) and each page it links, then draft from the User's decision:
17
+
18
+ - the App's types and fields, as its `schema.yaml` will declare them;
19
+ - each piece of work no page does, the function that does it and the Trigger that starts it;
20
+ - for each design on [Designs that load House](apps/design-load) the App comes near, how it keeps that load down;
21
+ - its top colours, light and dark, from the decided prototype, and its icon.
22
+
23
+ The draft is no document of its own: the grilling takes it in.
24
+
14
25
  ## Build
15
26
 
16
- Claim the build ticket. Starting from the User's decision, run `grill-with-docs`, then `to-spec`, then `implement`. Keep what they write where the Owner rules, `/rooms/private/owner-rules.md`, say. Each Skill named here is also a Command of its name, or the Guide page it links, for a client that has not installed it.
27
+ Run `grill-with-docs` on that draft, then `to-spec`, then `to-tickets`, then `implement` for each ticket in order. Keep what they write in the map's Room, where the Owner rules, `/rooms/private/owner-rules.md`, say, and name that Room in the App's README as the Room where the App is developed. Each Skill named here is also a Command of its name, or the Guide page it links, for a client that has not installed it.
17
28
 
18
29
  ## Publish into the same App
19
30
 
@@ -11,7 +11,7 @@ Keep everything where the Owner rules, `/rooms/private/owner-rules.md`, say. Eac
11
11
 
12
12
  ## 1. Open the map
13
13
 
14
- In the Room the work belongs to, the one the User names or else one you make with `rooms/create`, chart a map as `wayfinder` does, but skip its grilling: the way is set here.
14
+ In the Room where the App is developed ([Design an App](apps/design)), the one the User names or else one you make with `rooms/create`, chart a map as `wayfinder` does, but skip its grilling: the way is set here.
15
15
 
16
16
  - **Destination:** the App the User asked for, built and published.
17
17
  - **Notes:** this map's tickets do the work they name; the App's address and `a_` reference once the prototype is published.
@@ -24,6 +24,8 @@ In the Room the work belongs to, the one the User names or else one you make wit
24
24
 
25
25
  Claim the first ticket. Build three variants as [Build an App](apps/build) says, each a distinct visual direction (type, colour, layout, tone), not three shades of one. Each draws every main page with real-looking content, and a switcher on the page moves between them. Build only pages, their look and their flow: no tests, no data model, no reviews. Before publishing, look at each variant at phone and desktop width and redo any that looks generic or broken.
26
26
 
27
+ Write the App's first screen as static HTML in its entry document, which its script takes over once it runs, so the screen shows before any script has loaded; every release keeps it so.
28
+
27
29
  Publish it as a new App, which creates it:
28
30
 
29
31
  - where you run Git, with `npm run publish -- --name "<display name>"`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/skills",
3
- "version": "0.1.0-c221fe4e31976",
3
+ "version": "0.1.0-c22b29c83d792",
4
4
  "description": "The upstream Skills agents.house serves its agents, verbatim, and the texts it writes for them: the Guide, its own Skills among the pages, and the Agent instructions",
5
5
  "license": "MIT",
6
6
  "type": "module",