# 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](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below.

## 1. Wrap the process

```bash
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:

```bash
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:

```ts
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](/docs/guides/agent-proxy):

```ts
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}}",
  },
});
```

```toml
# 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:

```ts
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](/docs/guides/agent-proxy/in-process) 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:

```ts
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:

```ts
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:

```ts
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](/docs/guides/third-party-sync) is for.
