# Playbook apps

Use this guide when authoring or debugging a personal playbook **app** or a
hosted-app load. Playbook shape, optional documentation, and export JSDoc stay
in [Playbook authoring](/guides/playbook-authoring) (`playbook_authoring:guide`).
Integration smoke tests stay in
[Integration-backed playbook app happy path](/guides/integration-backed-app-happy-path)
(`integration_backed_app:guide`).

Open a heading with `search({ entity: "playbook_apps:guide#asset-urls" })` (or
another slug below) when you need one recipe.

## Session handoff

Production-hosted apps live at
`https://{username}.kody.run/playbooks/<kody-id>/…`. Opening the app from the
signed-in kody.codes origin (**Open app**, the returned `hosted_app_url`, or the
equivalent playbook page control) attaches a short-lived session, then the
subdomain loads. Plan QA around that path: signed-in origin first, then confirm
the app on `*.kody.run/playbooks/…`.

`playbookAppFetch` exercises the fetch handler without that browser session. Use
it for handler smoke tests. Use the handed-off URL for cookies, layout, OAuth
redirects, and websocket facets.

## Smoke with playbookAppFetch

After `playbookSave`, call `playbookAppFetch` with the path, method, and body
the handler needs. Confirm `{ status, headers, body, truncated }` and any
`playbookStorage()` side effects. Read
[Playbook app fetch](https://github.com/kentcdodds/kody/blob/main/docs/use/playbook-app-fetch.md) for the call shape.

Typical first probe:

```json
{
	"kody_id": "my-app",
	"path": "/"
}
```

Check status, content-type, and a small HTML or JS snippet in `body`. When
`truncated` is `true`, the handler ran; the MCP body is a size-capped sample
(about 100 KB). Side effects are real.

Inspect `playbookGet` for the current app entry and source.

## Interactive UI QA

Confirm the real user flow in a browser that already has the session, or in a
local harness that serves the **same** saved client and assets:

1. Open the app from kody.codes so the handoff attaches, **or** serve the saved
   entry, HTML, and asset routes locally with the same `appBasePath` /
   `hostedUrl` join the Worker uses.
2. Click, type, and submit the way a person would.
3. Confirm layout, redirects, and any websocket facet on that same client.
4. Then ping the owner.

`playbookAppFetch` stays the handler smoke. Interactive QA is the handed-off
browser or that local harness.

## Large binaries

`playbookAppFetch` is the lightweight smoke: status, headers, and a small body
sample. For a large download (WASM, WAD, video, zip), use a full download path —
`curl` against the handed-off or local harness URL, or the streamed app route
that serves those bytes — and confirm length, content-type, and that the file
opens in the client.

Treat `truncated: true` as “the handler answered,” then finish the proof on the
full stream.

## Asset URLs

Build every in-app asset URL, link, redirect, share/email URL, and OAuth
callback from `playbookContext.appBasePath` plus `hostedUrl` (or
`new URL(path, origin)` with a trailing-slash-safe origin). Kody strips the
mount before the handler runs, so the fetch sees `/<path>` only. Absolute
`/audio/123` links leave the mount; mount-prefixed URLs stay under
`/playbooks/<kody-id>/…` (or `/@username/playbooks/<kody-id>/…` when served
inline).

```ts
import { playbookContext } from 'kody:runtime'

function appUrl(path: string) {
	if (!playbookContext?.hostedUrl) {
		throw new Error('This module must run as a playbook app.')
	}
	const relative = path.replace(/^\/+/, '')
	const mount = playbookContext.appBasePath.endsWith('/')
		? playbookContext.appBasePath
		: `${playbookContext.appBasePath}/`
	return new URL(`${mount}${relative}`, playbookContext.hostedUrl)
}

const sprite = appUrl('assets/sprite.png')
const callback = appUrl('oauth/callback')
```

`hostedUrl` is the public mount URL. `appBasePath` is the origin-relative mount
(`/playbooks/<kody-id>` on a subdomain). Both come from the current serving
username and `kody.id`, including after a rename. When you pass a relative path
to `new URL(path, origin)`, give `origin` a trailing slash so
`assets/sprite.png` stays under the mount.

## Same-origin proxy

When the browser needs third-party bytes reliably (WASM, media, a vendor
script), add an app route that streams the upstream body from the Worker. The
page then fetches a same-origin `appUrl('…')` instead of a foreign host.

```ts
export default {
	async fetch(request: Request) {
		const path = new URL(request.url).pathname
		if (path === '/vendor/engine.wasm') {
			const upstream = await fetch('https://cdn.example.com/engine.wasm')
			return new Response(upstream.body, {
				status: upstream.status,
				headers: {
					'content-type':
						upstream.headers.get('content-type') ?? 'application/wasm',
				},
			})
		}
		return new Response('ok')
	},
}
```

Point the client at `appUrl('vendor/engine.wasm')`. The Worker holds the
upstream `fetch`; the browser stays on the playbook-app origin.

## Source size

Save complete text files only; binary source is unsupported. Serve permitted
large runtime payloads from a reviewed CDN or a streamed
[same-origin app route](#same-origin-proxy), retaining host and secret checks.
An oversized source save fails explicitly and leaves existing source unchanged.

## Compiled clients

When the app ships a compiled engine (WASM plus JS glue), read the **shipped**
glue and match its startup contract. Typical Emscripten-style glue accepts
`Module.arguments` plus a normal `run()`, and `wasmBinary` or `instantiateWasm`
when you supply the bytes:

```js
const Module = {
	arguments: ['--fullscreen'],
	wasmBinary: engineBytes,
}

document.querySelector('#engine-script').addEventListener('load', () => {
	Module.run?.()
})
```

Load a one-shot engine script **once** per page life (a single `<script>`
element, or one dynamic import). After a failed boot, recover with a full page
reload when the glue is not re-entrant.

## Save failures

Read the capability error first. Fix source/manifest validation errors without
replacing valid saved source. On edit conflict, get current files and token,
reconcile, then save. For secret or host approval, send the owner the returned
approval URL before testing.

See [Activity](https://github.com/kentcdodds/kody/blob/main/docs/use/activity.md) for run evidence and
[authoring](/guides/playbook-authoring) for complete-file editing.
