Odel

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:

KeyCarries
app.odel/contextIdentity — userId, displayName, conversationId, requestId, timestamp
app.odel/secretsThe 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 | undefined

getRequiredSecret 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.

On this page