Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

← All guides

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:

  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).

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.

Working with an agent? This guide is also plain markdown at /guides/playbook-apps.md, or load it over MCP with search({ entity: 'playbook_apps:guide' }).