@agentshouse/skills 0.1.0-c2653a0a4ef70 → 0.1.0-c279efd7eb4df

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.
@@ -4,7 +4,7 @@
4
4
  "Work with House through the `house` CLI, `house <tool> '<arguments as JSON>'`: `house --help` lists the Tools and `house help <tool>` names a Tool's arguments."
5
5
  ],
6
6
  "routing": [
7
- "Read with `shell` and `inspect`. Write with `files_batch` and `records_append`.",
7
+ "Read with `shell` and `inspect`. Write with `files_batch`; `records_append` adds a record from one of your Sources.",
8
8
  "For anything else, `find_command`, then `run_command`.",
9
9
  "Optional per-Room instructions: `/rooms/private/rooms/<handle>/AGENTS.md`."
10
10
  ],
@@ -2,7 +2,7 @@
2
2
 
3
3
  How to build an App over your User's Rooms and publish it, one page per step.
4
4
 
5
- An App is a web page over your User's Rooms, for your User and whom they share it with (`shares/create`). House serves the built files and gives the page the App runtime, which holds every bound Room in the browser and syncs it with House. There is no server, backend or House API of yours to call.
5
+ An App is a web page over your User's Rooms, for your User and whom they share it with (`shares/create`). House serves the built files and gives the page the App runtime, which holds every bound Room but a Search-level one in the browser and syncs it with House. There is no server, backend or House API of yours to call.
6
6
 
7
7
  - [Design an App](apps/design)
8
8
  - [Start an App](apps/start)
@@ -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
@@ -20,17 +21,17 @@ Or mount it as the Chat button, which takes no element:
20
21
  `const button = await houseBridge.chat.mountButton(options)` draws House's dot at the
21
22
  window's bottom-right corner, which counts what asks the User or, while nothing does, the chats
22
23
  that replied since the User opened them, and breathes while a turn runs; pressing it opens the
23
- Chat in a sheet above the page, and pressing it again hides it. With `agent` among the options
24
+ Chat in a sheet above the page, and a second press hides it. With `agent` among the options
24
25
  the dot's first press opens it on a new chat with that Agent instead of a chat kept from an
25
- earlier visit, and later presses reopen the chat it last showed; the sheet stays hidden until
26
- 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.
26
+ earlier visit, and later presses reopen the chat it last showed. 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
28
+ whose list, closed at first, widens it to the left by the list's own width; the page under it
29
+ never changes, so leave nothing of the page to make room for it.
29
30
  `button.open(target)` opens the sheet, on `{ conversation }` or on a new chat with
30
31
  `{ agent }`, or as it was with no target, and with `docked: true` docked at full height along
31
32
  the window's right edge; `button.hide()` hides it, keeping its chat, scroll and typed text;
32
33
  `button.update(options)` and `button.unmount()` work as for a page, with `onOpen` and
33
- `onLink`. To open on text written in, write it as the chat's draft through the runtime's
34
+ `onLink`, though a Room document's link opens House in a new tab with that chat. To open on text written in, write it as the chat's draft through the runtime's
34
35
  `chatDraft` first, keyed `new:<agent>` for a new chat. In the sheet the input's + holds This
35
36
  page, which attaches the page's Page metadata to one message: a new chat's first message starts
36
37
  with it ticked. Mount one button per page.
@@ -42,9 +43,10 @@ Theme its own styles with CSS custom properties on the element or an ancestor:
42
43
  `--chat-accent`, `--chat-line`, `--chat-attention`, `--chat-danger`, `--chat-font`,
43
44
  `--chat-font-mono`, `--chat-font-size`, `--chat-radius` and `--chat-density` (a factor for
44
45
  spacing); an unset one keeps House's light or dark look. For the Chat button, which takes no
45
- element, set them on the document's root element. Its dot is House's and takes none of them.
46
+ element, set them on the document's root element. Its dot keeps House's colours and type, and
47
+ stays level with the sheet's input.
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.
@@ -16,4 +16,6 @@ from a 360 pixel wide viewport to a desktop.
16
16
  Its strings are English only, dates and numbers follow the browser locale, and content is
17
17
  never translated. Light and dark themes come from CSS custom properties, defaulting to the
18
18
  browser's preference and respecting an App's override. What an agent adds carries none of
19
- these promises by itself. Data lives only in the App Creator's browser and the Provider.
19
+ these promises by itself. The App keeps no data of its own anywhere: its data lives in
20
+ its Rooms, at the Provider and on every device that holds them, and the
21
+ App's code sends it out only to the external origins its manifest declares.
@@ -5,6 +5,6 @@ Which part of House does each kind of an App's work, and what each run costs.
5
5
  - **The page** reads and writes the bound Rooms on the device, offline too, and runs only while someone has it open ([Local first](apps/local-first)).
6
6
  - **An App function** runs on House's servers when a Trigger of its Room fires, with no page open and no model. It reads and writes that Room and calls the services `external_origins` lists, within <function-time> and <function-memory> a run. It does what a script can: computing, filing, copying, counting, scheduling, calling a service ([App functions](apps/functions)).
7
7
  - **An Environment function** is a script your User kept for one of their computers, which Kit runs there as them. It does work that needs that computer, its files or its logins. A Trigger of their Private Room, their Run now or `environments/functions/run` starts one; a Trigger of any other Room never does ([wizard](skills/wizard)).
8
- - **An Agent function** starts a conversation of one of your User's Agents, which reads, judges and writes in minutes rather than milliseconds. Every run spends your User's own provider budget, and nothing bounds how many a Room's Triggers start. It does what needs judgment: reading a message, answering it, deciding what a document means ([Agent function](reference/agent-function)).
8
+ - **An Agent function** starts a conversation of one of your User's Agents, which reads, judges and writes in minutes rather than milliseconds. Every run spends your User's own provider budget, and House sets no allowance on these runs: one Trigger acts on at most <trigger-events-per-hour> Events in a UTC hour, so a Room's Triggers together can still start many. It does what needs judgment: reading a message, answering it, deciding what a document means ([Agent function](reference/agent-function)).
9
9
  - **A job that needs judgment in one step** splits: an App function does the rest, and an Agent function only that step, writing its answer into the Room, where a Trigger hands it on to the App function ([Agent function](reference/agent-function)).
10
- - **With no page open**, work happens only through a Trigger whose action notifies someone or runs one of these ([Triggers](reference/triggers)).
10
+ - **With no page open**, nothing of the App runs by itself: a Trigger whose action notifies someone or runs one of these starts its work ([Triggers](reference/triggers)).
@@ -6,6 +6,6 @@ What House draws around an App's page, and what the App sets for its look.
6
6
  - **The Chat**, where the page mounts it, draws in the App's frame: the Chat button's dot in House's look, and the Chat in its own, which the `--chat-*` properties theme ([The Chat](apps/chat)).
7
7
  - **The design system's tokens**: mdui's `style.css` holds House's design tokens as CSS custom properties, light and dark: its greys, type sizes, spacing, radii and motion. An App built on them looks like House; changing them gives it a look of its own. The design system itself is not published, so mdui's tokens are how an App takes it.
8
8
  - **Light and dark**: House's screens around the page, such as its wait until the page shows its place, follow the device's light or dark setting, as `prefers-color-scheme` does.
9
- - **The top colour**: declare the page's top colour as `theme_colour` in `app.json`, and `theme_colour_dark` where the dark one differs ([App manifest](reference/app-manifest)). An installed App's title bar and a phone's top strip take it, and House's white or near-black ground without it.
9
+ - **The top colour**: declare the page's top colour as `theme_colour` in `app.json`, and `theme_colour_dark` where the dark one differs ([App manifest](reference/app-manifest)). An installed App's title bar and a phone's top strip take it, and House's white or near-black ground without it. A page with its own light and dark switch calls `bridge.setTab({ scheme })` with `'light'` or `'dark'` when it shows one over the device's setting, and `null` when it follows the device, so they take that scheme's colour.
10
10
  - **The icon** is the owner's setting, never the release's: one image in the browser tab, the panel and on a home screen. It is the App cube in the App's colour until one is set, then a generated one from one to four colours or the owner's own square PNG or SVG, through `apps/tab-icon/set`; `bridge.setTab({ title, icon })` changes the open tab's title and icon until the next load.
11
11
  - **No focus mark**: House's own screens draw none, and [An App's page](apps/page) says how a page removes the browser's ring.
@@ -3,14 +3,14 @@
3
3
  How an App reads and writes its Rooms on the device, offline too.
4
4
 
5
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
- - 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.
6
+ - Draw from `observe` at once; an observation hears each later change by itself, so never poll, refetch or subscribe to House. 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
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
- - Offline, all of this works and saves wait; `bridge.onStatus` reports `offline`: say so quietly.
13
+ - Offline, all of this works for held Rooms and saves wait; `bridge.onStatus` reports `offline`: say so quietly.
14
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
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)).
@@ -7,9 +7,9 @@ hears it again only when it changes, keeping every unchanged Document the same o
7
7
  a component can tell what changed by identity. `{ documents, coverage }` or `{ groups,
8
8
  coverage }` is complete for the Rooms its `coverage` names, and `{ pending }` names each
9
9
  Room not yet acquired, prepared or unlocked, `[{ room, awaits }]`: never read it as empty,
10
- draw it as loading and observe the query to hear the complete answer. A query is data:
10
+ draw them absent and observe the query to hear the complete answer. A query is data:
11
11
 
12
- - **Filter.** `rooms` names Room locators and defaults to every Room. `types` names MD
12
+ - **Filter.** `rooms` names Room locators and defaults to every bound Room but a Search-level or locked one, which `pending` omits. `types` names MD
13
13
  Types, and `any` names untyped Markdown Documents. `pathPrefix` is a directory: `notes`
14
14
  keeps `notes/a.md` and not `notes2/a.md`; `outside` names directories whose contents stay
15
15
  out, `['house']` leaving `house/` out; `folder` keeps only what lies directly in it.
@@ -10,5 +10,4 @@ What an App's page draws and how it behaves in the browser.
10
10
  - An empty list says what it will hold and invites the first document.
11
11
  - Long names truncate with an ellipsis and show in full on hover.
12
12
  - The element is the link: a row, card or name opens its item; no "Open" buttons.
13
- - Offer installation yourself: `bridge.install()` from a click starts the install or shows the browser's prompt, or answers `manual` where you show how.
14
13
  - Offer a way out where your User expects one: `bridge.ceremony('/sign-out')` signs them out of House and opens sign-in.
package/guide/index.md CHANGED
@@ -4,6 +4,8 @@ Where House's guides and formats start: `guide/open` opens a page by its `path`
4
4
 
5
5
  House keeps Markdown files in Rooms, which your User shares with other people and their agents.
6
6
 
7
+ With the Kit, each Room your Environment keeps is also a Local copy: a Git repository at `local-copies/rooms/<room_ref>` in its workspace, `~/.agents/house` unless moved. `house fetch` records House's changes on `refs/house/received` in each, and `house push`, run in one, sends its commits.
8
+
7
9
  - `apps/`, building an App over your User's Rooms: [Design an App](apps/design) first, then [Build an App](apps/build), and the functions House runs for it when a Trigger fires: [App functions](apps/functions).
8
10
  - `reference/`, the formats House reads: [Access level](reference/access-level), [App manifest](reference/app-manifest), [Schema](reference/schema) and [Triggers](reference/triggers).
9
11
  - `agent-functions/`, the Agent function an Agent takes a plan's tickets by, written for the plan's Room and naming that Agent as [Agent function](reference/agent-function) says: [Work on ticket](agent-functions/work-on-ticket).
@@ -2,7 +2,7 @@
2
2
 
3
3
  How much of a Room a grant or a Share lets its holder do with its text, and where.
4
4
 
5
- Access level, `level` on a Room grant or a Share’s Room item, each level including the one before it: `search`, the Search level, reaches the Room through `shell` and `inspect` alone, each reply at most <mcp-read-size> of the Room’s text, with no copy anywhere; `open`, the Open level, also opens the Room in browser Apps while the browser is online, and acts as `search` everywhere else; `keep`, the Keep level, also copies the Room to the devices that keep it, offline included, and is the one `export` needs. Apps need `open` or `keep`.
5
+ Access level, `level` on a Room grant or a Share’s Room item, each level including the one before it: `search`, the Search level, reaches the Room through `shell` and `inspect`, each reply at most <mcp-read-size> of the Room’s text, and for App code only through the pages of rows [`queryHouse`](apps/query-house) asks House for, which no agent reaches, with no copy anywhere; `open`, the Open level, also opens the Room in browser Apps while the browser is online, and acts as `search` everywhere else; `keep`, the Keep level, also copies the Room to the devices that keep it, offline included, and is the one `export` needs. An App that reads the Room on the device, through `query` and `observe`, needs `open` or `keep`.
6
6
 
7
7
  ```json
8
8
  { "room_ref": "r_ab12.1", "level": "open", "operations": ["write"] }
@@ -2,7 +2,7 @@
2
2
 
3
3
  How a Room's `settings/schema.yaml` declares its document types.
4
4
 
5
- Schema, `/rooms/<room>/settings/schema.yaml`: one JSON Schema in YAML. Each top-level key is an MD type holding the schema of its documents' values; `$defs` holds shapes reused through `$ref: '#/$defs/<name>'`. An object setting neither `additionalProperties` nor `unevaluatedProperties` refuses other keys. House adds `reference` on a string at any depth, naming the types, or `attachment`, its value links to, and `cross_room: true` beside it, letting that be another Room's document of those types; a link or reference written as its `/rooms/<room>/` path is stored relative to the document; `title` on a field, the label a document shows for it, its key when it has none; `contentMediaType: text/markdown` on a string, Markdown text under its own heading; and `member_document: true` on one type, whose documents name their member with `member`, a User Identifier from `rooms/members/list`. On a type, `create_roles` lists the Schema Roles that may create its documents and `delete_roles` those that may delete them, and changing a document's `type` needs both; on a field of a type's own `properties`, `change_roles` lists those that may change it, `initial_roles` those that may set it on a new document, and `transitions` the `{from, to, roles}` changes it may make, `null` standing for absent; `owner` and `member` are built in, `[]` allows nobody, and only someone who manages the Room's access changes them. `files_batch` creates or replaces the whole text, and may rewrite the documents a change breaks in the same call.
5
+ Schema, `/rooms/<room>/settings/schema.yaml`: one JSON Schema in YAML. Each top-level key is an MD type holding the schema of its documents' values; `$defs` holds shapes reused through `$ref: '#/$defs/<name>'`. An object setting neither `additionalProperties` nor `unevaluatedProperties` refuses other keys. House adds `reference` on a string at any depth, naming the types, or `attachment`, its value links to, and `cross_room: true` beside it, letting that be another Room's document of those types; a link or reference written as its `/rooms/<room>/` path is stored relative to the document; `title` on a field, the label a document shows for it, its key when it has none; `contentMediaType: text/markdown` on a string, Markdown text under its own heading; and `member_document: true` on one type, whose documents name their member with `member`, a User Identifier from `rooms/members/list`. On a type, `create_roles` lists the Schema Roles that may create its documents and `delete_roles` those that may delete them, and changing a document's `type` needs both; on a field of a type's own `properties`, `change_roles` lists those that may change it, `initial_roles` those that may set it on a new document, and `transitions` the `{from, to, roles}` changes it may make, `null` standing for absent; `owner` and `member` are built in, `[]` allows nobody, and only someone who manages the Room's access changes them. `files_batch` creates, patches or replaces it, House checks the whole text it leaves, and the same call may rewrite the documents a change breaks.
6
6
 
7
7
  A Room in a Space shows its Space's schema at that path and keeps none of its own: its owner and a member who can write in the Space change it there, a rule keyword only someone who manages the Space, in an edit holding that change alone, checked against every document of every Room of the Space; a change that would break any document is refused naming it, so change that document in its own edit first. A Room taken out of its Space keeps the last version as its own. Every Room also knows the [Inbox types](reference/inbox-types) and the [Wayfinding types](reference/wayfinding-types) without declaring them.
8
8
 
@@ -13,9 +13,9 @@ Build it as [Build an App](apps/build) says, with what follows. Anything private
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
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).
17
- - `sites/describe` sets the title and description search engines show.
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.
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.
17
+ - `sites/describe` sets `google_analytics_id` and the title and description search engines show. With an ID, 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 `'decline'`, and keep a way on every page to decline later, which reloads the page unmeasured. A counter of its own: [A Site's own counter](sites/counter).
18
+ - A Site moved from another builder keeps its old links with `redirects` in `app.json`, `[{ "from": "/old", "to": "/new" }]`: each old path, as a browser asks for it, answers a permanent redirect to its new path, query included. No old path is under `/_house/`, a root file such as `/robots.txt` or an entry route's; none repeats or is a new path. A document published at an old path takes it over.
19
19
  - See it yourself: open its url, which needs no sign-in.
20
20
 
21
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.
@@ -0,0 +1,26 @@
1
+ # A Site's own counter
2
+
3
+ How a Site counts its visitors in a counter of its own, such as a web analytics counter or an ad pixel, beside `google_analytics_id` or instead of it.
4
+
5
+ - **Its script in the release.** A page runs only its release's scripts, so copy the counter's script into your source and bundle it, rather than load it from the counter's server as its snippet does. A script the counter loads from there later is refused too.
6
+ - **Its origins.** List every https origin the counter sends to in `app.json`'s `external_origins`. The browser console names each origin the page refused.
7
+ - **The page's address and referrer.** Your page runs in a frame of the Site's page at the Site's own origin, so the counter sees the frame's address and no referrer of the visitor's. Turn off its own page view and send each through `countPages` below, which gives the page's address and where the visitor came from: first the Site page's referrer, then the page they left.
8
+ - **Consent, as for `google_analytics_id`.** Start the counter only after the visitor accepts, never after Decline. Ask on the Site unless their answer is kept. While `bridge.onStatus` reports `analytics.measures`, the answer is `analytics.choice`, kept with `bridge.analyticsConsent`, so one banner asks once for both; otherwise keep it in local storage, and a browser that keeps none is never counted. Keep a way on every page to decline later, which reloads the page without the counter: `bridge.analyticsConsent('decline')` does that while `analytics.measures` holds, `parent.location.reload()` otherwise.
9
+ - **Hits arrive.** Publish, open the Site's url in a browser, accept and open a second page: the network log shows a request to the counter for each page, answered, and the counter's real-time report, which your User reads, shows both pages at the Site's address, the second with the first as its referrer.
10
+
11
+ `src/counter.ts`, which your entry script calls once the visitor accepted, with the counter's call that sends one page view:
12
+
13
+ ```ts
14
+ import { houseBridge } from '@agentshouse/app-bridge';
15
+
16
+ export function countPages(pageView: (page: string, referrer: string) => void): void {
17
+ let referrer = parent.document.referrer;
18
+ let counted = '';
19
+ houseBridge().onAddress((address) => {
20
+ const page = location.origin + address;
21
+ if (page === counted) return;
22
+ pageView(page, referrer);
23
+ referrer = counted = page;
24
+ });
25
+ }
26
+ ```
@@ -13,3 +13,4 @@ A phone on a slow link pays for every byte before the first screen, so what the
13
13
  - **At most two font families**, the Site's own, their files in the release.
14
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
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.
16
+ - **A common Site from a Site Template**, built this way, where `templates/list` answers them: `site-landing`, one page whose form sends each lead as a Submission; `site-services`, a page that lists each document of its Room's `services/` folder as one service; and `site-shop`, a shop on the recipe of [Payments with Stripe](sites/payments-stripe). Make the Site with one `templates/use` of it, at the address `templates/list` answers, with a `site_name` and a `room_name`: the Site gets its own App and a new Room holding the Template's schema and Starter content, keeps no link to the Template, and you change its App as any other. It binds that Room in every slot but one that declares `intake: true`, which publishes it. `site-landing`'s one slot declares it, so the Room is the Site's Intake Room and none is published: turn on Notify me with `sites/notifications/set` and each lead reaches your User on the channels they chose. `site-shop`'s `orders` slot declares it: set up its Intake Room as that recipe says.
@@ -9,3 +9,5 @@ How a Site's page calls a function its App declares and gets its answer, so code
9
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
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
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,68 @@
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
+ // Stripe's libraries refuse a signature over five minutes old, which stops a replayed delivery
10
+ const TOLERANCE_SECONDS = 300;
11
+
12
+ export default async (event, house) => {
13
+ if (event.kind !== 'items') throw new Error('paid runs on a delivery');
14
+ const header = [event.item.headers['stripe-signature'] ?? ''].flat().join(',');
15
+ const pairs = header.split(',').map((pair) => pair.split('='));
16
+ const [, stamp = ''] = pairs.find(([name]) => name === 't') ?? [];
17
+ if (!/^\d+$/.test(stamp) || Math.abs(Date.now() / 1000 - Number(stamp)) > TOLERANCE_SECONDS)
18
+ throw new Error('the delivery was not signed in the last five minutes');
19
+ const text = `${stamp}.${event.item.body}`;
20
+ let matched = false;
21
+ for (const [name, signature] of pairs) {
22
+ if (name !== 'v1' || matched) continue;
23
+ const checked = await house.call('service-keys/signatures/check', {
24
+ room_ref: event.room,
25
+ origin: STRIPE,
26
+ text,
27
+ signature,
28
+ });
29
+ if (checked.kind !== 'action') throw new Error(`the check was refused: ${checked.code}`);
30
+ matched = checked.result.matched;
31
+ }
32
+ if (!matched) throw new Error('the signature does not match');
33
+ const session = JSON.parse(event.item.body).data.object;
34
+ if (session.payment_status !== 'paid') return;
35
+ const path = `orders/${session.client_reference_id}.md`;
36
+ const query = { types: ['order'], paths: [path], select: { fields: ['/state'] } };
37
+ const [order] = (await house.query(query)).documents;
38
+ if (order === undefined) throw new Error(`no order at ${path}`);
39
+ if (order.fields.state === 'paid') return;
40
+ const fields = { state: { value: 'paid', base: { value: 'pending' } } };
41
+ const email = session.customer_details?.email;
42
+ if (typeof email === 'string') fields.email = { value: email, base: null };
43
+ const saved = await house.saveParts(event.room, [{ path, parts: { fields } }]);
44
+ if (saved.outcome !== 'saved') throw new Error(`the order was not saved: ${saved.code}`);
45
+ };
46
+ ```
47
+
48
+ `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`.
49
+
50
+ `functions/order.js`, which answers `pending`, `paid`, or `null` for an id that names no order:
51
+
52
+ ```js
53
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
54
+
55
+ export default async (call, house) => {
56
+ const order = call.input?.order;
57
+ if (call.kind !== 'site' || typeof order !== 'string' || !UUID.test(order)) return null;
58
+ const { documents } = await house.query({
59
+ rooms: [call.intake_room],
60
+ types: ['order'],
61
+ paths: [`orders/${order}.md`],
62
+ select: { fields: ['/state'] },
63
+ });
64
+ return documents[0]?.fields.state ?? null;
65
+ };
66
+ ```
67
+
68
+ 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.
@@ -2,6 +2,13 @@
2
2
 
3
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
+ A Room the Site binds for queries only never reaches a visitor's browser: `rooms` lists it
6
+ with `queriesOnly: true` and nothing held, and its rows come a page at a time through
7
+ `queryHouse`, as [House query rules](apps/query-house) say, except that a query of more than
8
+ about 9,000 bytes, too long for the Site's address, answers `query_too_large` with no `cap`
9
+ before it is sent. Every visitor asking the same query gets the same page, which the edge keeps
10
+ for a few minutes, so a change to the Room shows within them.
11
+
5
12
  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
13
 
7
14
  On a Site, every Save stays in the visitor's browser: write grants and revisions do not refuse it,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/skills",
3
- "version": "0.1.0-c2653a0a4ef70",
3
+ "version": "0.1.0-c279efd7eb4df",
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",