Playbook apps
Official Kody guide
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 (playbook_authoring:guide).
Integration smoke tests stay in
Integration-backed playbook 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 for the call shape.
Typical first probe:
{
"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:
- Open the app from kody.codes so the handoff attaches, or serve the saved
entry, HTML, and asset routes locally with the same
appBasePath/hostedUrljoin the Worker uses. - Click, type, and submit the way a person would.
- Confirm layout, redirects, and any websocket facet on that same client.
- 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).
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.
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, 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:
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 for run evidence and authoring for complete-file editing.