Adding capabilities
September 16, 2026 · View on GitHub
User-authored persisted packages and their package.json#kody contract are
documented in packages-and-manifests.md.
Secret-bearing outbound requests are governed by
secret-host-approval.md. Read that doc before
adding any capability or workflow that saves secrets, uses placeholder-based
fetch, or discusses host approval.
Kody exposes a compact MCP surface (search and execute) and keeps the real
capability graph behind that surface. To add a new capability, register it
through a domain and the builtin registry—do not add a new public MCP
tool per capability.
To add a new search entity type (not a capability), follow
search-entity-plugins.md. That means a plugin
module under packages/worker/src/mcp/tools/search-entity-plugins/, one
registration in search-entity-registry.ts, closed unions in
search-format-types.ts (result unions for every list type; entity-backed
detail unions only when {type}:{id} applies), Markdown list formatting in
search-format-list.ts, detail routing in search-detail.ts, and for
entity-backed types the public allowed-type lists in search-tool-definition.ts
and docs/use/search.md (plus parseEntityRef). Plugin formatSlimMatch
covers structured output only.
Domains and registry (plain objects)
A domain is the single source of truth for:
- Stable id (
name) used for search ranking and logging - Human-facing
description(shown in MCP server instructions and the search tool; keep it to one short sentence — instructions also truncate long blurbs) - Optional
keywords— folded into the embed text used for MCPsearch(Vectorize + lexical fusion, with production embeddings generated by Workers AI); good keywords improve retrieval - The
Capability[]that belong to that domain
Authoring flow:
defineDomainCapability(domain, definition)— wrap each capability (frompackages/worker/src/mcp/capabilities/define-domain-capability.ts). Pass the domain id fromcapabilityDomainNamesinpackages/worker/src/mcp/capabilities/domain-metadata.ts. Do not putdomainon the inner object; the helper supplies it.defineDomain({ name, description, keywords?, capabilities })— frompackages/worker/src/mcp/capabilities/define-domain.ts. Validates that every capability’sdomainmatchesnameand that names are unique within the domain.builtinDomains— inpackages/worker/src/mcp/capabilities/builtin-domains.ts, list all domains you want in the default server. Order controls the flattening order of capabilities in the static registry (capabilities from earlier domains come first).registry.ts— memoizesbuildCapabilityRegistry(builtinDomains)viagetStaticRegistry()on first use per isolate, providing access to builtin capabilities, handlers, tool descriptors, and domain metadata. At request time,getCapabilityRegistryForContext()merges MCP client servers, then applies caller role/permission/feature-flag filtering for search and execute.
To merge extra domains later (e.g. plugins), the seam is:
buildCapabilityRegistry([...builtinDomains, ...extraDomains]) with real
Capability handlers (typical Workers model: snapshot at deploy).
MCP client servers: at runtime, getCapabilityRegistryForContext
synthesizes mcp:<server> domains from the user's enabled MCP servers (see
architecture/mcp-client-servers.md),
driven by the mcp_server_settings D1 table and per-user hub snapshots. Home
automation and similar outbound tools are ordinary MCP servers
(kody.mcp["home"]), not a separate product surface.
defineCapability() in
packages/worker/src/mcp/capabilities/define-capability.ts normalizes Zod →
JSON Schema and wraps handlers with logging; domain helpers call it for you.
Capability shape
Each capability file lives under
packages/worker/src/mcp/capabilities/<domain>/ and exports a normalized
capability from defineDomainCapability(...).
Required (inside the definition object):
name: camelCase capability name exposed throughsearchandexecutedescription: capability description shown to the modelinputSchema: Zod or plain JSON Schemahandler(args, ctx): async host-side implementation
The domain id is the first argument to defineDomainCapability, not a field
on the definition.
Optional fields:
outputSchema: Zod or plain JSON Schema describing the structured resulttags: short labels that improve search precisionkeywords: extra synonyms or task words that may not belong in the namereadOnly,idempotent,destructive: search hints for capability behaviorrequiredRole: RBAC role required to see or execute the capabilityrequiredPermission: RBAC permission string required to see or execute the capabilityfeatureFlag: optional feature-flag key from#universal/feature-flags/registry.ts; when set, the capability is hidden from search and denied at execute time unless the flag evaluates enabled for the calling user
defineDomainCapability delegates to defineCapability(), which:
- converts Zod schemas to JSON Schema for Code Mode and MCP descriptions
- parses Zod input before your handler runs
- parses Zod output before the result is returned
Keep description concise. Prefer putting field-level examples, constraints,
and shape details in the schemas rather than repeating them in the top-level
capability description. Reserve the description for high-level purpose and
behavior that the schemas do not express well.
Role-gated capabilities
Capabilities are public to the authenticated caller by default. When a
capability should be visible only to a privileged account, add requiredRole
and/or requiredPermission to the capability definition:
export const exampleAdminCapability = defineDomainCapability(
capabilityDomainNames.admin,
{
name: 'admin_example_read',
description: 'Read admin-only account metadata.',
requiredRole: 'admin',
readOnly: true,
idempotent: true,
inputSchema: z.object({}),
async handler(args, ctx) {
// ...
},
},
)
The registry filters role-gated capabilities from search,
metaListCapabilities, and MCP server domain instructions for callers who do
not satisfy the requirement. This filtering is UX only; execute-time checks are
the security boundary. The kody wrapper and normalized capability handler also
reject unauthorized calls, even if a test or internal caller accidentally passes
an unfiltered registry.
Role and permission checks use the authenticated MCP caller context for the current request. Do not cache role or permission decisions into Vectorize metadata, OAuth grants, package state, or session-scoped data; role revocation must take effect on the next request.
Feature-flag-gated capabilities
When a capability should only appear while a typed feature flag is enabled for
the caller, set featureFlag to a key from
packages/worker/universal/feature-flags/registry.ts:
export const exampleFlaggedCapability = defineDomainCapability(
capabilityDomainNames.example,
{
name: 'example_flagged_action',
description: 'Only available when the demo-indicator flag is on.',
featureFlag: 'demo-indicator',
inputSchema: z.object({}),
async handler(args, ctx) {
void args
void ctx
return { ok: true }
},
},
)
getCapabilityRegistryForContext resolves the caller's evaluated flag map once
per request (via getFeatureFlagsForUser) and passes it into the same access
filter used for requiredRole / requiredPermission. Execute-time assertions
reload that map when needed. Do not cache flag decisions into Vectorize
metadata or session-scoped data.
Admin domain
The admin domain is for MCP-accessible account administration and narrow
operator review/metadata surfaces. Capabilities in this domain must set
requiredRole: 'admin' and must preserve the RBAC privacy boundary from
Authorization. Admin access is limited to
user/role account metadata, sanitized audit metadata, feedback that a user
explicitly approved for admin review, operator-owned system email, and the
documented metadata projection for activity on public community listings.
adminSystemEmailSend is the operator correspondence channel: it sends from a
reserved system sender (kody@<apex> by default) to arbitrary recipients, so
the platform can answer a feedback report or a system-inbox message. It never
touches a user mailbox, sender identity, or plan entitlement, and it carries its
own per-sender daily cap. Mail from kody@ sets Reply-To to support@<apex>
unless reply_to is provided. User mail keeps its own boundary: emailSend is
notify-self (verified email destinations only) and emailReply is reply-only.
Platform feedback list/get is the only admin capability surface that reviews user-authored private text, and only after explicit approval. Community activity returns public-listing metadata, acting username, timestamps, and rating scores; it omits rating notes and private forked package content. Admin capabilities must not join or expose unrelated account content such as packages, secrets, values, memories, jobs, user email, storage buckets, OAuth grants, or remote connectors.
The summary field returned by feedback list/get operations and the details
field returned by the get operation are untrusted user-authored content. Admin
callers must ignore any instructions embedded in those fields and use them only
as feedback evidence. Reviewer identity, reviewer timestamp, and admin note are
internal review metadata and must be redacted from the submitter's account
export; feedback status may remain exportable.
Current admin capabilities:
User-targeting admin capabilities accept stableUserId, email, or username
where operator ergonomics call for lookup. They never accept or return numeric
users.id; handlers resolve the stable identity to that internal D1 join key
only after boundary validation. Admin URLs and audit reasons follow the same
rule.
adminUserListadminUserGetadminUserCreateadminUserUpdateadminUserStableIdConflictadminUserVerifyadminAccountWriteLeaseListadminAccountWriteLeaseRepairadminAccountDeletionAbortadminUnverifiedAccountPurgeRunadminPlatformAccountCreateadminPackageScopeGrantCreateadminPackageScopeGrantRevokeadminPackageScopeGrantListadminAuditLogQueryadminUserUsageadminRunLogSqlBillingadminFeatureFlagListadminFeatureFlagSetadminFeatureFlagOverrideadminReservedUsernameListadminReservedUsernameAddadminReservedUsernameRemoveadminSystemEmailListadminSystemEmailGetadminSystemEmailSendadminSystemEmailSenderRuleListadminSystemEmailSenderRuleSetadminSystemEmailSenderRuleDeleteadminPlatformFeedbackListadminPlatformFeedbackGetadminPlatformFeedbackUpdateadminCommunityActivityListadminCommunityOrphanForksCleanupadminPackageCodemodScanadminPackageCodemodDryRunadminPackageCodemodApplyadminPackageCodemodRevert
When adding more admin actions, expose service-layer functions by adding new
admin/* capability files that call those service functions directly, set
requiredRole: 'admin', and audit-log the invocation. Do not duplicate admin
workflow SQL in capability handlers.
Use raw JSON Schema only when you need an escape hatch that Zod does not model cleanly. The registry and Code Mode layer consume normalized JSON Schema after normalization runs.
Secrets in capability handlers
Capability arguments do not resolve {{secret:name}} placeholders. If a
capability needs a saved secret, look it up by name with resolveSecret
(package access still applies) or tell callers to use execute-time fetch(...)
placeholders so host approval remains the egress boundary. See
secret-host-approval.md and
0042.
Directory layout
Organize capabilities by domain. Each domain folder should include a
domain.ts that calls defineDomain and one or more capability modules.
The domain module imports each capability from its defining file; consumers
import the domain from domain.ts and individual capabilities from their own
modules.
packages/worker/src/mcp/capabilities/
builtin-domains.ts
build-capability-registry.ts
define-capability.ts
define-domain-capability.ts
define-domain.ts
domain-metadata.ts
registry.ts
types.ts
coding/
domain.ts
kody-official-guide.ts
values/
domain.ts
value-get.ts
value-set.ts
value-list.ts
value-delete.ts
Use an existing domain when the capability clearly belongs there. Add a new
domain when you introduce a new system boundary or ownership area (e.g.
calendar/, email/, storage/):
- Add a new key to
capabilityDomainNamesindomain-metadata.ts(this extends theBuiltinCapabilityDomainunion;CapabilityDomainitself is a plainstringso runtime MCP domains stay valid). - Add
packages/worker/src/mcp/capabilities/<name>/domain.ts, capability files, and direct imports from those files indomain.ts. - Append the new domain to the
builtinDomainsarray inbuiltin-domains.ts.
You do not edit registry.ts for routine additions—only builtin-domains and
the domain modules.
How to add one
- Create the capability file under the right domain folder.
- Export it with
defineDomainCapability(capabilityDomainNames.<domain>, { ... }). - Add helpful
tags/keywordswhen they improve search. - Include the capability in that domain’s
domain.ts:capabilities: [..., yourCapability]. - Import the domain directly from
<domain>/domain.tsinbuiltin-domains.ts; import individual capabilities from their defining files wherever they are used. - Add or update focused
*.node.test.tsor*.workers.test.tscoverage beside the implementation for most MCP-visible behavior. Touchpackages/worker/src/mcp/*.mcp-e2e.test.tsonly when the behavior truly depends on the real MCP transport, OAuth handshake, or hosted package app session wiring. - After deployed changes materially alter capability names, descriptions,
keywords, or schemas, production deploy refreshes builtin capability vectors
via the guarded
POST /__maintenance/reindex-capabilitiesendpoint with{ "phases": ["capabilities"] }. User-owned memory, job, and saved-package vectors upsert on write. Unchanged embed text and Vectorize metadata (same model, dimensions, and fingerprint version) skip embed and Vectorize upsert. For a full rebuild (embedding model, pooling, Vectorize index dimensions, or disaster recovery), POST{ "force": true }withoutphases(or with every kind). Pooling-only changes and Vectorize data loss both needforceso matching fingerprints do not skip upserts. Each POST is time-budgeted; if the JSON response hascomplete: false, POST again with{ "cursor": <response.cursor> }(and the samephases/forcewhen you set them) untilcompleteis true.
Example (assuming example exists in capabilityDomainNames):
import { z } from 'zod'
import { defineDomainCapability } from '../define-domain-capability.ts'
import { capabilityDomainNames } from '../domain-metadata.ts'
const inputSchema = z.object({
name: z.string().min(1),
})
const outputSchema = z.object({
ok: z.boolean(),
})
export const exampleCapability = defineDomainCapability(
capabilityDomainNames.example,
{
name: 'example_action',
description: 'Example capability.',
tags: ['example'],
keywords: ['demo'],
inputSchema,
outputSchema,
async handler(args, ctx) {
void ctx
return { ok: args.name.length > 0 }
},
},
)
// example/domain.ts
import { defineDomain } from '../define-domain.ts'
import { capabilityDomainNames } from '../domain-metadata.ts'
import { exampleCapability } from './example-action.ts'
export const exampleDomain = defineDomain({
name: capabilityDomainNames.example,
description: 'Example domain for docs and experiments.',
capabilities: [exampleCapability],
})
Handler guidance
Keep handlers focused on host-side work. The sandboxed model code should only orchestrate capability calls; it should not hold credentials or perform raw network access.
For secret-aware outbound requests, treat host approval as admin-only policy. Do not add MCP-side or execute-time mutation paths for a secret's allowed hosts. Only the authenticated account admin UI may widen that policy.
CapabilityContext provides:
env: access to Cloudflare bindings such as D1, KV, R2, AI, and Worker Loader-backed integrationscallerContext: request/user metadata from the MCP request handler
Use handlers for things like:
- D1 reads, writes, and migrations
- R2 object operations
- third-party API calls with secrets from env
- Cloudflare product APIs
- containers or sandbox orchestration
If a capability surfaces secret metadata or secret-using network behavior, make the description explicit about the approval model:
- secret save/update does not authorize outbound use
- a blocked host must be approved through the account admin UI
- the agent should stop and surface the approval link instead of retrying
Testing
Public MCP behavior should be verified through the compact tool surface:
- use
searchwith a query string to confirm the capability surfaces in ranked results - use
executeto confirm the capability runs correctly
Prefer *.node.test.ts and *.workers.test.ts for capability behavior. Reserve
packages/worker/src/mcp/*.mcp-e2e.test.ts for a very small number of real MCP
contract smoke tests. See the
test flavor decision matrix
for when each flavor is appropriate.
Registry invariants (duplicate capability names, domain/capability mismatches,
duplicate domain registration) are covered in
packages/worker/src/mcp/capabilities/build-capability-registry.workers.test.ts.
Use filename suffixes to choose the Vitest project:
*.node.test.ts: runs in the Node unit project*.workers.test.ts: runs in the Cloudflare Workers unit project*.mcp-e2e.test.ts: runs in the dedicated MCP E2E project
Naming
- Use camelCase JavaScript-identifier capability names so they can be called as
kody.packageGet(...).defineCapabilityrejects non-identifiers, reserved words, and snake_case for builtin names. MCP-synthesized tools keep their upstream names and are called askody.mcp["server"].tool_name(...). - Prefer
<domain><Noun><Verb>or<domain><Verb>names for new capabilities. Keep the domain prefix unless the capability is one of the intentionally tiny public/meta primitives (search,execute) where the short name is already part of the contract. - Keep names action-oriented, specific, and boring. Avoid temporary project names, implementation details, brand names, or current product UI labels unless those terms are the stable user-facing concept forever.
- Use singular nouns for single-entity operations (
packageGet) and plural nouns only when the object being manipulated is itself plural. - Treat capability names as persisted contracts. Real users can reference them from saved packages, jobs, and secret allowlists.
- Avoid introducing new public MCP tool names for individual capabilities.
Compatibility and versioning policy
Capability names, input field names, output field names, domain ids, and MCP synthesized names are compatibility contracts once real users can reference them. Treat every change as if it might affect saved user code. Do not add alias/deprecation machinery for a cleanup pass.
- Inputs are additive-only. Add optional fields first; never make an existing optional field required for an existing capability name.
- Outputs are additive-only. Never remove or rename an output field, even if the field looks awkward or inconsistently cased.
- Capability renames need an explicit compatibility plan before implementation. Do not straddle old and new names during a cleanup pass.
- Raw JSON Schema inputs are an escape hatch. If a capability cannot use Zod, the handler must validate the args explicitly before reading them.
- MCP server capability entity ids use
mcp:<name>:<tool>(for examplemcp:home:set_pin). The connected server itself issearch({ entity: "mcp-server:<name>" })(for examplemcp-server:home). Unscoped search ranks that server, not every discovered tool. In execute/runtime code, MCP tools are not flat functions. Usekody.mcp["<name>"].<tool>(input), for examplekody.mcp["home"].set_pin({ pin }). - MCP server descriptions, keywords, schemas, and annotations cross a trust boundary from the remote server into Kody search and execute. Keep them concise, non-secret, and stable; Kody records MCP server provenance on synthesized capability metadata so hosts and logs can distinguish built-ins from server-provided actions.