seekrit
Docs/Mastra

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.

note

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 createTool that 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 with paths set — not just allow.
  • @ai-sdk/openai calls /v1/responses, not /v1/chat/completions. Current versions default to the Responses API, so an allowlist that pins paths: ["/v1/chat/completions"] denies every request from this stack. Use ["/v1/**"], or pin /v1/responses deliberately. The CLI's openai preset already uses /v1/**; it is hand-written allowlists that get this wrong.
  • The object model form cannot carry the substitution. Mastra's OpenAICompatibleConfig takes id, url, apiKey and headers but no fetch, so shapes 4 and 5 need an AI SDK provider instance. Pointing its url at 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 .env either. On a developer machine it belongs in ~/.config/seekrit/config.json via seekrit login; in a deploy it is the one variable your platform holds, which is what third-party sync is for.