Mastra
Mastra reads provider credentials from the environment, so the zero-code shape
covers local development. Its model object form — which takes a url and an
apiKey — is a clean hook for the proxy.
Start here if seekrit is new: three
commands put the keys in
an environment, mint a token bound to it, and export SEEKRIT_TOKEN. A token
reads everything in its
environment, so
there is no per-key or per-framework setup to do before any of the below.
1. Wrap the process
seekrit run -- mastra dev
seekrit run -- pnpm dev
Secrets land in the child process only. If you have a .env today, import it
once and delete it:
seekrit secrets import .env && rm .env
seekrit run still overlays a .env if one exists, and process environment wins
over both — so migrating is safe, but leaving the file behind defeats the point.
2. Resolve in code
For a deployed Mastra server, resolve before the agent is constructed:
import { Seekrit } from "@seekrit/sdk";
import { Agent } from "@mastra/core/agent";
const secrets = await new Seekrit().resolve();
export const supportAgent = new Agent({
id: "support",
name: "Support Agent",
instructions: "You are a helpful support agent",
model: {
id: "openai/gpt-5.6-sol",
apiKey: secrets.OPENAI_API_KEY,
},
});
The SDK is pure WebCrypto and fetch, so this is the same code on Node, Bun,
Deno, and Cloudflare Workers. On a Worker there is no ambient environment — pass
the token explicitly: new Seekrit({ token: env.SEEKRIT_TOKEN }).
3. Never hold the key
The object form takes a url, so point it at the proxy:
export const supportAgent = new Agent({
id: "support",
name: "Support Agent",
instructions: "You are a helpful support agent",
model: {
id: "custom/gpt-5.6-sol",
url: "http://127.0.0.1:8080/openai/v1",
apiKey: "{{seekrit:OPENAI_API_KEY}}",
},
});
# seekrit-proxy.toml
listen = "127.0.0.1:8080"
[[route]]
prefix = "/openai"
upstream = "https://api.openai.com"
allow = ["OPENAI_API_KEY"]
methods = ["POST"]
paths = ["/v1/chat/completions"]
Without running the proxy
Mastra accepts an AI SDK provider instance anywhere it accepts a
'provider/model' string, so the shim drops in as a model:
import { createOpenAI } from "@ai-sdk/openai";
import { seekritFetch } from "@seekrit/sdk/fetch";
const openai = createOpenAI({
apiKey: "{{seekrit:OPENAI_API_KEY}}",
fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }),
});
export const supportAgent = new Agent({
id: "support",
name: "Support Agent",
instructions: "You are a helpful support agent",
model: openai("gpt-5.6-sol"),
});
Weaker than the proxy, since it runs in your process: in-process injection sets out the trade-off.
4. A different key per request
Mastra types model as DynamicArgument<MastraModelConfig> — a model or a
function of { requestContext } — which makes per-tenant credentials a
first-class thing rather than a workaround. @seekrit/sdk/mastra returns that
function form:
import { createOpenAI } from "@ai-sdk/openai";
import { Agent } from "@mastra/core/agent";
import { seekritModel } from "@seekrit/sdk/mastra";
export const supportAgent = new Agent({
id: "support",
name: "Support Agent",
instructions: "You are a helpful support agent",
model: seekritModel(
({ apiKey, fetch }) => createOpenAI({ apiKey, fetch })("gpt-5.6-sol"),
{
secret: "OPENAI_API_KEY",
allow: { "api.openai.com": ["OPENAI_API_KEY"] },
scope: (rc) => ({ with: { tenants: String(rc.get("tenant")) } }),
},
),
});
apiKey is the placeholder; fetch substitutes the value this tenant's token
resolves. Your builder runs per request, so the model id, temperature or even
the provider can vary by tenant too — but the fetch behind it is shared per
scope, which is the part that matters: a fresh one per request would resolve on
every request and quietly undo the cache. maxScopes (default 64) bounds how
many tenants' resolved sets are held.
Populate the request context from your own edge:
import { Mastra } from "@mastra/core/mastra";
import { seekritRequestContext } from "@seekrit/sdk/mastra";
export const mastra = new Mastra({
agents: { supportAgent },
server: { middleware: [seekritRequestContext({ header: "x-tenant" })] },
});
That middleware trusts the header, which is right behind your own authenticated edge and wrong facing the internet. There, set the key from a verified session in your own middleware instead — a caller who can pick the header can pick the tenant.
5. A key one tool may spend and the others may not
A provider key buys tokens. A tool's Stripe key does something irreversible.
Mastra passes requestContext to createTool executors, so a tool can be given
a fetch narrowed to exactly its own secrets:
import { createTool } from "@mastra/core/tools";
import { seekritToolFetch } from "@seekrit/sdk/mastra";
import { z } from "zod";
const refundFetch = seekritToolFetch({
allow: { "api.stripe.com": ["STRIPE_SECRET_KEY"] },
only: ["STRIPE_SECRET_KEY"],
scope: (rc) => ({ with: { tenants: String(rc.get("tenant")) } }),
label: "tool:refund",
});
export const refund = createTool({
id: "refund",
description: "Refund a charge",
inputSchema: z.object({ chargeId: z.string() }),
execute: async ({ chargeId }, context) => {
const response = await refundFetch(context)("https://api.stripe.com/v1/refunds", {
method: "POST",
headers: { authorization: "Bearer {{seekrit:STRIPE_SECRET_KEY}}" },
body: new URLSearchParams({ charge: chargeId }),
});
return response.ok ? "refunded" : `refund failed: ${response.status}`;
},
});
only is a ceiling on the tool, not on its caller: it applies whether or not a
request context arrived. Build the fetch at module scope, as above — building it
inside execute gives every tool call its own cache and its own resolve.
Gotchas
- Mastra's tools are where the risk is. An agent with a
createToolthat calls your API is holding that credential in the same process as the model output. Those keys are the argument for shape 5 above, or for shape 3 withpathsset — not justallow. @ai-sdk/openaicalls/v1/responses, not/v1/chat/completions. Current versions default to the Responses API, so an allowlist that pinspaths: ["/v1/chat/completions"]denies every request from this stack. Use["/v1/**"], or pin/v1/responsesdeliberately. The CLI'sopenaipreset already uses/v1/**; it is hand-written allowlists that get this wrong.- The object model form cannot carry the substitution. Mastra's
OpenAICompatibleConfigtakesid,url,apiKeyandheadersbut nofetch, so shapes 4 and 5 need an AI SDK provider instance. Pointing itsurlat the proxy is the other way to hold a placeholder there, and a stronger one. - Workflows outlive a single resolve. A long-running workflow holds whatever it resolved at start. If a step needs a credential that may rotate mid-run, resolve inside the step rather than at module scope.
- Don't put the seekrit token in
.enveither. On a developer machine it belongs in~/.config/seekrit/config.jsonviaseekrit login; in a deploy it is the one variable your platform holds, which is what third-party sync is for.