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:
- Open the app from kody.codes so the handoff attaches, or serve the
published 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.
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
docsor 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:
| Field | Confirm |
|---|---|
license | The license string you meant to show |
pinned_commit | The commit you just published |
description | Short tagline (kody.description) |
tags | Search keywords |
category | integrations, examples, productivity, apps, or utilities |
kody_id | Package slug |
public_url | /@username/kody-id (share this URL with people) |
version | package.json#version when you set one |
Share public_url with humans. Hygiene before going public stays in
Package authoring.