# In-process injection

The [egress proxy](/docs/guides/agent-proxy) gives you a strong property: your
code holds `{{seekrit:OPENAI_API_KEY}}` and never the key. It also asks you to
run a process — `seekrit proxy run` these days, so not much of an ask.

The SDKs ship the same substitution engine as a `fetch` wrapper and an `httpx`
transport, so you can have the placeholder without the process. It is a weaker
boundary — see [what this does and does not
guarantee](#what-this-does-and-does-not-guarantee) — and it is one import.

Either way you need a seekrit token, the same one `seekrit run` uses: it is what
resolves the value the placeholder stands for. If you don't have one yet, that is
[three commands](/docs/guides/frameworks#get-running-in-five-minutes).

## TypeScript

```bash
npm install @seekrit/sdk
```

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

That covers more than the AI SDK. [Mastra](/docs/guides/frameworks/mastra)
accepts an AI SDK provider instance anywhere it accepts a `'provider/model'`
string, and LangChain.js passes `configuration` straight through to the OpenAI
client:

```ts
import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  apiKey: "{{seekrit:OPENAI_API_KEY}}",
  configuration: { fetch: seekritFetch({ allow: { "api.openai.com": ["OPENAI_API_KEY"] } }) },
});
```

### Mastra

Mastra types `model` as a model *or* a function of `{ requestContext }`, which
makes a different key per request a first-class thing. `@seekrit/sdk/mastra`
returns that function form, and shares one `fetch` per scope so the resolve stays
cached:

```ts
import { createOpenAI } from "@ai-sdk/openai";
import { seekritModel } from "@seekrit/sdk/mastra";

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

It also carries `seekritRequestContext` (server middleware that lifts a tenant
header into the request context) and `seekritToolFetch` (a `fetch` narrowed to
one tool's secrets). See the
[Mastra page](/docs/guides/frameworks/mastra) for the whole shape.

## Python

```bash
pip install 'seekrit[httpx]'
```

```python
import httpx
from openai import OpenAI
from seekrit.transport import SeekritTransport

transport = SeekritTransport(allow={"api.openai.com": ["OPENAI_API_KEY"]})

client = OpenAI(
    api_key="{{seekrit:OPENAI_API_KEY}}",
    http_client=httpx.Client(transport=transport),
)
```

One transport covers every Python toolkit, because they all reach the network
through the same `http_client=`:

```python
allow = {"api.openai.com": ["OPENAI_API_KEY"]}
transport = SeekritTransport(allow=allow)
async_transport = AsyncSeekritTransport(allow=allow)

# LangChain / LangGraph
ChatOpenAI(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client(transport=transport),
           http_async_client=httpx.AsyncClient(transport=async_transport))

# Pydantic AI
OpenAIProvider(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.AsyncClient(transport=async_transport))

# OpenAI Agents SDK
set_default_openai_client(AsyncOpenAI(api_key="{{seekrit:OPENAI_API_KEY}}",
                                      http_client=httpx.AsyncClient(transport=async_transport)))

# LlamaIndex
OpenAI(api_key="{{seekrit:OPENAI_API_KEY}}", http_client=httpx.Client(transport=transport))
```

Use `AsyncSeekritTransport` for the async client; the arguments are identical.

CrewAI goes through LiteLLM, whose only seam is a module global:

```python
import litellm
litellm.client_session = httpx.Client(transport=transport)
litellm.aclient_session = httpx.AsyncClient(transport=async_transport)
```

That works, but it is process-wide — per-request scoping is not expressible
there.

## The allowlist is the point

The placeholder is not the security boundary; the allowlist is. Without one, a
substitution engine just moves the key from your source to your imports.

```python
from seekrit.transport import AllowRule, SeekritTransport

transport = SeekritTransport(
    rules=[
        AllowRule(
            host="api.openai.com",
            methods=("POST",),
            paths=("/v1/chat/completions", "/v1/embeddings"),
            allow=("OPENAI_API_KEY",),
        ),
        AllowRule(
            host="api.stripe.com",
            methods=("POST",),
            paths=("/v1/refunds",),
            allow=("STRIPE_SECRET_KEY",),
        ),
    ],
)
```

Default-deny throughout, and the same semantics the proxy enforces: first
matching rule wins, an empty `methods` or `paths` means *any*, an empty `allow`
means *no secret*. `*` matches within one path segment and `**` across several.
A refusal names the constraint that refused — `secret_not_allowed` is a different
problem from `path_not_allowed`, and a default-deny rule set fails in exactly
the confusing direction.

`AllowRule` is the wire shape of a rule inside a signed
[`ap1.` policy bundle](/docs/guides/agent-proxy/policy), so rules from a bundle
you have verified can be passed in unchanged.

Two things always fail closed, and neither sends the request:

- a placeholder the allowlist refuses toward this host, method, or path
- a placeholder that names a secret your token did not resolve

Forwarding the literal placeholder would leak an internal name to the upstream;
substituting anyway would hand a credential to a host that is not allowed to
have it. The refusal carries the secret's **name** and never its value.

### How a refusal reaches you

A refusal answers with the same **403** the proxy answers with, and never sends
the request:

```
403 placeholder {{seekrit:STRIPE_SECRET_KEY}} is not allowed toward this upstream
x-seekrit-refusal: denied
x-seekrit-secret: STRIPE_SECRET_KEY
```

That is deliberate, and it is the one place where the obvious implementation is
wrong. A provider SDK wraps anything its HTTP layer *throws* into its own opaque
error **and retries it** — with LangChain's defaults, a denied placeholder became
`APIConnectionError: Connection error.` after six retries, with the real reason
buried on `err.cause`. As a 403 the same mistake surfaces once, immediately, as
`PermissionDeniedError: 403 placeholder {{seekrit:STRIPE_SECRET_KEY}} is not
allowed toward this upstream`. It also means swapping this shim for the proxy
does not change your error handling: both answer 403.

The `x-seekrit-refusal` header tells our refusal apart from a real upstream 403.
If you call the shim yourself rather than handing it to an SDK, ask for the
typed error instead — `refusal: "throw"` in TypeScript, `refusal="raise"` in
Python — and use `onRefuse`/`on_refuse` to observe either way.

A failure to **resolve** — the seekrit API being unreachable — always raises,
in both modes. That one is genuinely transient and worth a retry; a denial
never is.

## Scoping a key to one tool

An agent's provider key buys tokens. Its Stripe key does something
irreversible. The interesting question is not "does this process hold the key"
but "may *this tool call* use it" — and on LangChain that is expressible.

```bash
pip install 'seekrit[langchain]'
```

```python
from langchain.agents import create_agent
from seekrit.langchain import SeekritCredentials
from seekrit.transport import SeekritTransport

transport = SeekritTransport(
    allow={
        "api.openai.com": ["OPENAI_API_KEY"],
        "api.stripe.com": ["STRIPE_SECRET_KEY"],
    },
    require_scope=True,
)

agent = create_agent(
    model=model,          # built with http_client=httpx.Client(transport=transport)
    tools=[refund, search],
    context_schema=Context,
    middleware=[
        SeekritCredentials(
            scope=lambda ctx: {"tenants": ctx.tenant},
            model=["OPENAI_API_KEY"],
            tools={"refund": ["STRIPE_SECRET_KEY"]},
        ),
    ],
)
```

The `refund` tool may substitute the Stripe key and nothing else. `search` may
substitute nothing at all — when `tools` is given it is exhaustive, so a tool
that is not named there gets an empty allowlist. A prompt-injected `search`
call cannot reach a payment credential, and the model call itself is narrowed to
the provider key.

`scope` also picks *which* secrets to resolve: returning
`{"tenants": ctx.tenant}` resolves that tenant's slice of a composed group, so
one agent process serves many tenants without ever holding two tenants' keys at
once. Note what this does **not** do: it never rebuilds the chat model. A model
constructs its HTTP client once, so a per-tenant model instance would mean a
per-tenant object cache and a per-tenant connection pool. The middleware sets an
ambient scope instead, and the transport resolves against it when the request
goes out.

Pair the middleware with `require_scope=True`, as above. The scope travels in a
context variable, which propagates into the same task and into threads started
with `asyncio.to_thread`, but not through every possible executor hop. With
`require_scope` set, a lost scope refuses the request; without it, a lost scope
would fall back to the transport's unnarrowed rules — which is the wrong
direction to fail.

### Pydantic AI

`WrapperToolset.call_tool` wraps every tool invocation with `ctx.deps` in scope,
so the same per-tool narrowing works there:

```bash
pip install 'seekrit[pydantic-ai]'
```

```python
from pydantic_ai.toolsets import FunctionToolset
from seekrit.pydantic_ai import SeekritToolset

toolset = SeekritToolset(
    FunctionToolset([refund, search]),
    scope=lambda deps: {"tenants": deps.tenant},
    tools={"refund": ["STRIPE_SECRET_KEY"]},
)
```

A toolset only wraps *tools*, and Pydantic AI builds its provider when the agent
is constructed — so wrap the run in `use_scope` to cover the model call too. The
two compose: the run establishes the tenant, the toolset narrows per tool. See
the [Pydantic AI page](/docs/guides/frameworks/pydantic-ai).

## What this does and does not guarantee

This runs in your process. Code in that process can read the resolved value or
replace the transport, so it is **not** the trust boundary the proxy is. Think
of it as a ladder:

| Rung | Boundary | Cost |
| --- | --- | --- |
| Environment variables | Anything in the process can read them, and so can anything that can read the process's environment | Nothing |
| In-process injection | The value exists only inside one HTTP call | One import |
| [The proxy](/docs/guides/agent-proxy) | A separate process; your code never has the value at all | `seekrit proxy run`, or `npx @seekrit/proxy` |
| [Temporary access](/docs/concepts/temporary-access) | The credential did not exist before the request and stops working after | A lease |

What the middle rung genuinely buys you:

- **Nothing in the environment.** No `.env`, no `os.environ`, no
  `process.env` — so an environment-scraping bug in a dependency finds nothing.
  That is not hypothetical: `CVE-2025-68664` in `langchain-core` (CVSS 9.3)
  defaulted `secrets_from_env=True` on deserialization, which let a crafted
  payload read any named environment variable back out.
- **Nothing in model context.** The value never becomes a tool argument, a tool
  result, a prompt, or a log line. In an agent that is the leak path that
  actually fires — every message passes through a trace exporter.
- **A real allowlist.** Per host, per method, per path, default-deny.

What it does not buy you: protection from the code holding the placeholder. When
that code is a [sandbox](/docs/guides/sandboxes), model-generated, or an agent
whose next action you cannot predict, use the proxy — and note that the rung
above is now one command (`seekrit proxy run`, or `npx @seekrit/proxy` with no
Node install of ours), not a build step. The gap between these two rungs is
smaller than it was, so prefer the stronger one when you can.

Only requests that *carry a placeholder* are gated. A request without one passes
straight through untouched — this is a credential shim, not an egress firewall,
and silently blocking unrelated traffic would be a worse lie than not blocking
it. For default-deny on *all* egress, that is the forward proxy.

## Options

Both shims take the same set.

| Option | Default | What it does |
| --- | --- | --- |
| `allow` | — | `{host: [names]}` shorthand: those names toward that host, any method or path |
| `rules` | — | Full rules, host by host. Combines with `allow` |
| `token` / `apiUrl` | `$SEEKRIT_TOKEN` | The service token to resolve with |
| `client` | — | A pre-built client, used when no scope overrides are in play |
| `scope` | ambient | Called per request to narrow the resolve and the allowlist |
| `ttlSeconds` / `ttl_seconds` | `60` | How long a resolved set is reused per scope. `0` resolves every time |
| `body` | `true` | Also scan the request body |
| `require_scope` | `false` | Refuse a placeholder-carrying request when no scope is in effect (Python) |
| `refusal` | `"respond"` | `"respond"` answers 403; `"throw"` / `"raise"` raises the typed error |
| `onInject` / `on_inject` | — | Called after a substitution with host, method, path and names. Never values |
| `onRefuse` / `on_refuse` | — | Called on every refusal, whichever way it surfaces |

## Gotchas

- **A streamed request body is never scanned.** Buffering it here would break
  streaming uploads, so put placeholders in headers — which is where credentials
  go anyway. Header and URL substitution always happen.
- **A substituted value is never rescanned.** If a secret's value happens to
  contain `{{seekrit:OTHER}}`, that text is emitted literally. A stored value
  cannot be used to reach a second secret.
- **`ttlSeconds` is a real trade-off.** The default reuses a resolved set for a
  minute per scope, so a rotation takes up to a minute to be picked up. Set `0`
  for correctness at the cost of one round trip per model call.
- **In TypeScript, pass the URL as a string.** A placeholder in the URL of a
  `Request` *object* is refused rather than sent, because the URL of a
  constructed `Request` cannot be rewritten. Headers on a `Request` are
  substituted normally.
- **`@ai-sdk/openai` calls `/v1/responses`, not `/v1/chat/completions`.**
  Current versions default to the Responses API, so a rule pinned to
  `paths: ["/v1/chat/completions"]` denies every request from that stack —
  fail-closed, but confusing. `["/v1/**"]` covers both. LangChain's
  `ChatOpenAI` and LiteLLM still use chat completions, so the two families need
  different pins if you constrain paths at all.
- **A refusal looks like an upstream 403 unless you check the header.** That is
  the trade for not being retried six times. `x-seekrit-refusal` is there to
  disambiguate, and `on_refuse` fires locally.
- **The seekrit token is still a credential.** It is the one thing the shim
  itself must hold, and it decrypts everything the token grants. Keep it out of
  the environment too: `seekrit login` writes
  `~/.config/seekrit/config.json` on a developer machine, and in a deploy it is
  the single variable your platform holds.
