Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

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

← All guides

Package apps

Official Kody guide

Use this guide when authoring or debugging a package app, a community fork of an app, or a hosted-app load. Package shape, README / AGENTS.md, Intent, and export JSDoc stay in Package authoring (package_authoring:guide). Integration smoke tests stay in Integration-backed package app happy path (integration_backed_app:guide).

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

Session handoff

Production-hosted apps live at https://{username}.kody.run/packages/<kody-id>/…. Opening the app from the signed-in kody.codes origin (Open app, the publish hosted_app_url, or the equivalent package 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/packages/….

packageAppFetch 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 packageAppFetch

After publish, call packageAppFetch with the path, method, and body the handler needs. Confirm { status, headers, body, truncated } and any packageStorage() side effects. Read Package 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.

Copy-paste starting points land on test_hints.app after packagePublishExternalPush.

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 published client and assets:

  1. Open the app from kody.codes so the handoff attaches, or serve the published 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.

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

Large binaries

packageAppFetch 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 packageContext.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 /packages/<kody-id>/… (or /@username/packages/<kody-id>/… when served inline).

import { packageContext } from 'kody:runtime'

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

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

hostedUrl is the public mount URL. appBasePath is the origin-relative mount (/packages/<kody-id> on a subdomain). Both come from the current serving username and kody.id, including after a rename or fork. 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 package-app origin.

Lean forks

Keep the package source cheap to communityFork: modest raw assets in the repo (icons, small sprites, HTML/JS). Serve heavy runtime payloads from a CDN or a streamed same-origin app route. Forks copy default-branch HEAD; a smaller tree finishes faster and stays under isolate limits.

A fork that dies on memory or CPU returns a capability error that the listing was unchanged. Lean the tree, then fork again.

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.

Fork failures

When communityFork or one-click install fails, read the capability error text first, then the run or delivery logs on Activity (runs domain). Match the next step to that failure:

  • “too large to finish forking” — slim the listing source (Lean forks), then retry
  • repo docs or check failures — add README / AGENTS.md or fix the named check, then publish
  • secret or host approval — send the owner the approval URL, then smoke-test

The error text is the source of truth for which of those paths you are on.

Listing verification

After communityPublish (or packageUpdate with changes.visibility: "public"), call communityGet with the listing id and confirm the card matches intent:

FieldConfirm
licenseThe license string you meant to show
pinned_commitThe commit you just published
descriptionShort tagline (kody.description)
tagsSearch keywords
categoryintegrations, examples, productivity, apps, or utilities
kody_idPackage slug
public_url/@username/kody-id (share this URL with people)
versionpackage.json#version when you set one

Share public_url with humans. Hygiene before going public stays in Package authoring.

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