Repository navigation
Add external package invocation API #275
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kentcdodds
merged 8 commits into
main
from
cursor/-bc-8a687cc4-2537-4d83-a751-7e00439960e2-9107
Apr 27, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
2ddc0fb
Add external package invocation API
cursoragent a29cb24
Store package invocation tokens in D1
cursoragent 759dd2a
Harden package invocation auth and idempotency
cursoragent 63f2641
Tighten package invocation token scopes
cursoragent 8ce8ec4
Handle stale package invocation rows safely
cursoragent f5f5264
Harden package invocation HTTP auth
cursoragent 0932ac2
Refine package invocation API edge cases
cursoragent d52596b
Forward package invocation audit context
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| # External package invocation API | ||
|
|
||
| Use this API when a trusted external service should invoke a saved package | ||
| export inside Kody without going through the interactive MCP transport. | ||
|
|
||
| The first intended caller is a stable Discord Gateway proxy: | ||
|
|
||
| 1. the external proxy owns the long-lived Discord websocket | ||
| 2. the proxy normalizes gateway events | ||
| 3. the proxy calls Kody's package invocation API | ||
| 4. Kody executes the saved package export with package context, user context, | ||
| package storage, and normal secret/capability rules | ||
|
|
||
| Kody remains the package runtime and storage brain. The external service owns | ||
| the socket lifecycle. | ||
|
|
||
| ## Endpoint | ||
|
|
||
| `POST /api/package-invocations/:packageIdOrKodyId/:exportName` | ||
|
|
||
| Examples: | ||
|
|
||
| - `/api/package-invocations/pkg_123/dispatch-message-created` | ||
| - `/api/package-invocations/discord-gateway/dispatch-message-created` | ||
|
|
||
| The path accepts either: | ||
|
|
||
| - the saved package id | ||
| - the package `kody.id` | ||
|
|
||
| The export name is normalized to package export form, so | ||
| `dispatch-message-created` resolves as `./dispatch-message-created`. | ||
|
|
||
| ## Authentication | ||
|
|
||
| Authentication uses a private bearer token stored in Kody's database-backed | ||
| package invocation token table. | ||
|
|
||
| Kody stores only the token hash for request-time lookup. The raw bearer token is | ||
| generated or collected by the token-management surface, shown to the operator | ||
| once, and then sent by the trusted external service as: | ||
|
|
||
| ```http | ||
| Authorization: Bearer <raw-token> | ||
| ``` | ||
|
|
||
| Each token row includes: | ||
|
|
||
| - token id and human-readable name | ||
| - owning `user_id`, `email`, and `display_name` | ||
| - package scope (`package_ids_json` and/or `package_kody_ids_json`) | ||
| - allowed package exports (`export_names_json`) | ||
| - allowed request sources (`sources_json`) | ||
| - `last_used_at` | ||
| - `revoked_at` | ||
|
|
||
| The token is not a global backdoor: | ||
|
|
||
| - package lookup is still user-owned | ||
| - package access is scoped by the token row | ||
| - export access requires an explicit allowlist | ||
| - `source` metadata is checked against the allowlist when provided | ||
| - tokens can be revoked without deploys | ||
| - execution still uses normal package runtime machinery | ||
|
|
||
| ## Request body | ||
|
|
||
| ```json | ||
| { | ||
| "params": { | ||
| "messageId": "123", | ||
| "content": "hello" | ||
| }, | ||
| "idempotencyKey": "discord:message-create:123", | ||
| "source": "discord-gateway", | ||
| "topic": "discord.message.created" | ||
| } | ||
| ``` | ||
|
|
||
| Fields: | ||
|
|
||
| - `params` — JSON object passed to the package export as runtime params | ||
| - `idempotencyKey` — required stable key for replay protection | ||
| - `source` — optional source label for auditing and token scoping; when present, | ||
| it must match the token's `sources_json` allowlist | ||
| - `topic` — optional event topic label for downstream logic and logs | ||
|
|
||
| ## Idempotency | ||
|
|
||
| Kody persists package invocation idempotency in D1. | ||
|
|
||
| The identity key is: | ||
|
|
||
| - user | ||
| - token id | ||
| - package id | ||
| - export name | ||
| - idempotency key | ||
|
|
||
| Behavior: | ||
|
|
||
| - same request + same idempotency key => stored response replayed | ||
| - same idempotency key + different payload => `409 idempotency_mismatch` | ||
| - duplicate while first invocation is still active => | ||
| `409 invocation_in_progress` | ||
|
|
||
| This makes duplicate Discord gateway deliveries safe when the proxy retries. | ||
|
|
||
| ## Response shape | ||
|
|
||
| Success: | ||
|
|
||
| ```json | ||
| { | ||
| "ok": true, | ||
| "package": { | ||
| "id": "pkg_123", | ||
| "kodyId": "discord-gateway" | ||
| }, | ||
| "exportName": "./dispatch-message-created", | ||
| "source": "discord-gateway", | ||
| "topic": "discord.message.created", | ||
| "idempotency": { | ||
| "key": "discord:message-create:123", | ||
| "replayed": false | ||
| }, | ||
| "result": { | ||
| "reply": "handled" | ||
| }, | ||
| "logs": [] | ||
| } | ||
| ``` | ||
|
|
||
| Replay responses return the same stored body with `idempotency.replayed: true`. | ||
|
|
||
| Failures return: | ||
|
|
||
| ```json | ||
| { | ||
| "ok": false, | ||
| "error": { | ||
| "code": "package_not_found", | ||
| "message": "Saved package \"discord-gateway\" was not found for this user." | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Execution failures return sanitized structured errors and logs. The route does | ||
| not expose Worker secrets directly. | ||
|
|
||
| ## Runtime behavior | ||
|
|
||
| The API reuses the existing published package bundle path: | ||
|
|
||
| - resolve the saved package for the configured user | ||
| - resolve the requested package export | ||
| - load the published `module` artifact if present | ||
| - rebuild and persist the module artifact on cache miss | ||
| - execute through `runBundledModuleWithRegistry` | ||
|
|
||
| Execution includes: | ||
|
|
||
| - package context | ||
| - repo context when a published source exists | ||
| - writable package storage bound to `package:<packageId>` | ||
| - user context from the scoped token config | ||
|
|
||
| ## Rate limiting and auditing | ||
|
|
||
| The endpoint is shaped for standard Cloudflare edge rate limiting: | ||
|
|
||
| - path is stable and narrow | ||
| - caller metadata includes `source` and `topic` | ||
| - each request is audit-logged with hashed email/IP metadata | ||
|
|
||
| Prefer Cloudflare WAF/rate limiting rules in front of this path rather than | ||
| adding bespoke in-Worker rate limiting first. | ||
|
|
||
| ## Discord gateway proxy pattern | ||
|
|
||
| Recommended flow: | ||
|
|
||
| 1. keep the Discord Gateway websocket in a stable external process | ||
| 2. receive `MESSAGE_CREATE` | ||
| 3. derive a stable idempotency key from the gateway event | ||
| 4. call the Kody endpoint | ||
| 5. use Kody's structured result to decide what to post back to Discord | ||
|
|
||
| Example request: | ||
|
|
||
| ```bash | ||
| curl --fail --silent \ | ||
| -X POST \ | ||
| -H "Authorization: Bearer $PACKAGE_INVOCATION_TOKEN" \ | ||
| -H "Content-Type: application/json" \ | ||
| "https://kody.example.com/api/package-invocations/discord-gateway/dispatch-message-created" \ | ||
| -d '{ | ||
| "params": { | ||
| "messageId": "123", | ||
| "channelId": "456", | ||
| "content": "hello" | ||
| }, | ||
| "idempotencyKey": "discord:message-create:123", | ||
| "source": "discord-gateway", | ||
| "topic": "discord.message.created" | ||
| }' | ||
| ``` | ||
|
|
||
| ## Related | ||
|
|
||
| - [Packages and manifests](./packages-and-manifests.md) | ||
| - [Environment variables](./environment-variables.md) | ||
| - [Setup manifest](./setup-manifest.md) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| CREATE TABLE IF NOT EXISTS package_invocation_tokens ( | ||
| id TEXT PRIMARY KEY, | ||
| user_id TEXT NOT NULL, | ||
| name TEXT NOT NULL, | ||
| token_hash TEXT NOT NULL UNIQUE, | ||
| email TEXT NOT NULL, | ||
| display_name TEXT NOT NULL, | ||
| package_ids_json TEXT NOT NULL DEFAULT '[]', | ||
| package_kody_ids_json TEXT NOT NULL DEFAULT '[]', | ||
| export_names_json TEXT NOT NULL DEFAULT '[]', | ||
| sources_json TEXT NOT NULL DEFAULT '[]', | ||
| created_at TEXT NOT NULL, | ||
| updated_at TEXT NOT NULL, | ||
| last_used_at TEXT, | ||
| revoked_at TEXT | ||
| ); | ||
|
|
||
| CREATE INDEX IF NOT EXISTS idx_package_invocation_tokens_user_id | ||
| ON package_invocation_tokens(user_id); | ||
|
|
||
| CREATE INDEX IF NOT EXISTS idx_package_invocation_tokens_token_hash | ||
| ON package_invocation_tokens(token_hash); | ||
|
|
||
| CREATE TABLE IF NOT EXISTS package_invocations ( | ||
| id TEXT PRIMARY KEY, | ||
| user_id TEXT NOT NULL, | ||
| token_id TEXT NOT NULL, | ||
| package_id TEXT NOT NULL, | ||
| package_kody_id TEXT NOT NULL, | ||
| export_name TEXT NOT NULL, | ||
| idempotency_key TEXT NOT NULL, | ||
| request_hash TEXT NOT NULL, | ||
| source TEXT, | ||
| topic TEXT, | ||
| status TEXT NOT NULL CHECK (status IN ('in_progress', 'completed', 'failed')), | ||
| response_json TEXT, | ||
| created_at TEXT NOT NULL, | ||
| updated_at TEXT NOT NULL | ||
| ); | ||
|
|
||
| CREATE UNIQUE INDEX IF NOT EXISTS idx_package_invocations_identity | ||
| ON package_invocations( | ||
| user_id, | ||
| token_id, | ||
| package_id, | ||
| export_name, | ||
| idempotency_key | ||
| ); | ||
|
|
||
| CREATE INDEX IF NOT EXISTS idx_package_invocations_user_created_at | ||
| ON package_invocations(user_id, created_at); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sourceis documented as optional but is effectively required by the runtime.service.tstokenAllowsSource()returnsfalsewhensourceis null/empty, which causesinvokePackageExportto return403 source_not_allowedbefore any package or export is even resolved. So a request omittingsourcealways fails — it isn't "optional" in practice. Either tighten this doc to saysourceis required (and that it must match the token'ssources_jsonallowlist), or updatetokenAllowsSourceto treat a missingsourceas "no source claim, skip allowlist check" if you actually want it optional.The same friction shows up in the curl example at lines 196–205, where
sourceis included — readers could reasonably remove it based on the "optional" description and get a confusing 403.🤖 Prompt for AI Agents