Context and secrets
Who is calling your tool, and the credentials resolved for them.
Before a call reaches your module, Odel's proxy has already worked out who is asking and which credentials belong to them. It passes both to you in the request, and this page is about reading them.
Two envelopes, not one
The proxy injects into the JSON-RPC request's params._meta under two namespaced keys:
| Key | Carries |
|---|---|
app.odel/context | Identity — userId, displayName, conversationId, requestId, timestamp |
app.odel/secrets | The per-user secret map for this module |
They are separate on purpose. Identity is safe to log, trace, and attach to an error report; the secret map is not. Keeping them apart means log scrubbing can target one key precisely instead of trying to recognise a credential by its shape.
The official SDK surfaces params._meta to handlers as extra._meta, so everything below reads
from the handler's second argument. You never touch the raw request body.
import { getModuleContext, getRequiredSecret } from '@odel/module-sdk';
server.registerTool('send', { /* … */ }, async (args, extra) => {
const ctx = getModuleContext(extra);
const apiKey = getRequiredSecret(extra, 'RESEND_API_KEY');
// …
});If you need the key strings themselves — writing a test harness, say — they are exported as
CONTEXT_META_KEY and SECRETS_META_KEY.
Identity
getModuleContext(extra) returns a ModuleContext:
interface ModuleContext {
/** Hashed user ID — stable per user, not reversible */
userId: string;
/** Hashed conversation ID, present when the call came from a chat turn */
conversationId?: string;
displayName?: string;
/** Unix milliseconds */
timestamp: number;
/** UUID for tracing this one call */
requestId: string;
}userId is a hash. It is stable, so you can key storage on it and recognise a returning user, but
it is not an email address and cannot be turned back into one. That is the whole point: a module
gets continuity without getting an identity.
When no Odel context is present — someone calling your server directly, or a local test — you get
anonymous defaults rather than an error: userId is 'anonymous', displayName is
'Anonymous User', and timestamp and requestId are freshly generated per call. Your handler
runs either way, so guard on ctx.userId === 'anonymous' if a tool genuinely requires a known user.
With Worker bindings
createToolContext(extra, env) bundles the same identity with your typed Cloudflare bindings:
interface MyEnv {
MY_KV: KVNamespace;
}
const ctx = createToolContext<MyEnv>(extra, env);
await ctx.env.MY_KV.put(ctx.userId, '1');ctx.env is also where your own
secret bindings land — values you set in the developer
portal, which are static and identical for every caller. Everything below is the opposite kind.
The caller's secrets
These belong to whoever is calling right now. Each user configures their own when they install the module, and they are resolved per request — so the same running worker hands your handler a different value for a different caller, seconds apart.
Which names a user gets asked for is your decision, declared either in
Required Secrets on the
project's Overview tab or in code as a configSchema. Identity —
userId, displayName and the rest — arrives without you declaring anything.
They are not fields on ModuleContext; they ride in the other envelope key, so identity can be
logged without dragging credentials along. Read them from extra:
const apiKey = getRequiredSecret(extra, 'RESEND_API_KEY'); // throws ModuleError if absent or empty
const hook = getOptionalSecret(extra, 'WEBHOOK_URL'); // string | undefinedgetRequiredSecret throws ModuleError.missingSecret, which
serializes into a response the caller can actually act on, rather than a TypeError from
somewhere deeper in your code.
Not the same as your bindings
getRequiredSecret(extra, …) reads only this lane. Your own API keys, set in the portal, are on
ctx.env and are invisible here — and a user's key is never on ctx.env. The question that
separates them: would two different users need different values? If yes it belongs here; if no
it belongs in a binding.
Values arrive resolved. Whether the user typed a literal into the field or pointed it at an item in their vault, your handler receives the same plain string and cannot tell which — deliberately, so nothing in a module has to know how a credential is stored.
Two things follow from that:
- Read them per call; don't hold them across requests. They're resolved fresh every time, so a user who rotates a vault item is on the new value at their next call, with no redeploy and nothing to invalidate. A value your module cached is the only stale copy in the system.
- Never log one or put it in a tool result. The vault keeps values out of logs, errors, and audit records; a module is the one place that guarantee can be broken.
Where these values come from
Users set a module's secrets when they install it, and can pick a vault item instead of typing a value. How the vault works covers the storage model and how a pick is resolved.
Declaring config once
Reading secrets one at a time works, but it scatters the answer to "what does this module need?" across every handler. Declaring a Zod schema puts it in one place:
import { parseConfig } from '@odel/module-sdk';
import { z } from 'zod';
export const configSchema = z.object({
RESEND_API_KEY: z.string().min(1).describe('Resend API key'),
FROM_ADDRESS: z.string().email().optional().describe('Override the sender address')
});
// inside a handler:
const cfg = parseConfig(configSchema, extra); // { RESEND_API_KEY: string; FROM_ADDRESS?: string }parseConfig validates the whole secrets envelope against the schema and hands back a typed
object, so cfg.RESEND_API_KEY is a string rather than a string | undefined you have to
check. It distinguishes the two ways config can be wrong: a missing key throws
ModuleError.missingSecret, while a key that is present but fails the schema throws
ModuleError.invalidSecret with the reason.
Passing the schema to createOdelServer({ …, configSchema }) also publishes it at
odel://config, where .describe() on each
field becomes the description a client can show. configRequiredSecretNames(configSchema) returns
just the non-optional names, and buildConfigManifest(configSchema) returns the manifest itself.
The portal's required-secrets list is separate
Declaring a configSchema does not populate the Required Secrets list on your project's
Overview tab — that list is typed in by hand, and it is what users are prompted for at install
time. Keep the two in step yourself.