GUIDES

The per-user vault

Every user brings their own API key or token. Store it once, encrypted, and let their tools use it without the model ever seeing it.

What the vault is for

Your tools often act on behalf of the person chatting, not on behalf of you. Alice's GitHub token, Bob's Stripe key, each customer's CRM credential. The vault holds one secret per user, encrypted with Fernet at rest, and injects it into tool auth at call time. The raw secret is placed in the outbound request, never in the prompt, so the model can trigger the action without ever reading the credential.

FieldTypeDescription
EncryptionFernetSymmetric authenticated encryption. Values are encrypted at rest and decrypted only to build the outbound tool request.
Namestring1 to 64 characters of letters, digits, or _ . - — this is the handle you reference in a template. Anything else returns INVALID_NAME.
Value size8192 bytesMeasured as UTF-8. Enough for tokens, keys, even short PEM blocks. Over it returns 413 VALUE_TOO_LARGE; an empty value returns 400 VALUE_REQUIRED.
Per-user cap50 credsFifty named secrets per user per project. A 51st new name returns 409 CRED_CAP_REACHED; overwriting an existing name is always allowed.
Write throttle30/min, 300/dayPer user per project, on PUT only. Over it returns 429 RATE_LIMIT_EXCEEDED with a Retry-After header. Reads and deletes are not throttled.

Provision a secret from your backend

You write secrets server to server, never from the browser. Send the user's signed identity plus the name and value to PUT /v1/credentials. Signed identity proves the request is really for that user, so a client cannot write into someone else's vault. The signer below mirrors the server's canonical JSON, escaping every non-ASCII character to its \uXXXX form so an Arabic or other Unicode identity still produces a matching signature.

PUT/v1/credentials
javascript
import { createHmac } from "crypto";

// Canonical JSON, byte-for-byte what Agentifys verifies against:
//   json.dumps(ctx, sort_keys=True, separators=(",", ":"))   # ensure_ascii=True by default
// Three things diverge if you hand-roll this with JSON.stringify:
//   1. every non-ASCII char must become \uXXXX (an Arabic name signs wrong otherwise),
//   2. keys sort by Unicode CODE POINT, not UTF-16 code unit (differs above the BMP),
//   3. only integers are safe (Python renders 1e-7 as 1e-07).
function enc(v) {
  return JSON.stringify(v).replace(/[^ -~]/g,
    c => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
}
const codePoints = (s) => Array.from(s, (c) => c.codePointAt(0));
function byCodePoint(a, b) {
  const A = codePoints(a), B = codePoints(b);
  for (let i = 0; i < Math.min(A.length, B.length); i++) if (A[i] !== B[i]) return A[i] - B[i];
  return A.length - B.length;
}
function canonicalJson(v) {
  if (Array.isArray(v)) return "[" + v.map(canonicalJson).join(",") + "]";
  if (v && typeof v === "object") {
    return "{" + Object.keys(v).sort(byCodePoint)
      .map((k) => enc(k) + ":" + canonicalJson(v[k])).join(",") + "}";
  }
  if (typeof v === "number" && !Number.isInteger(v)) {
    throw new Error("Sign integers only: " + v + " formats differently in Python. Send it as a string.");
  }
  return enc(v);
}

// Add _ts (unix seconds), sign the context WITHOUT _sig, then send { ...identity, _ts, _sig }.
// Valid for 5 minutes, and re-checked on every /v1/chat call.
function signUserContext(identity, secret) {
  const ctx = { ...identity, _ts: Math.floor(Date.now() / 1000) };
  ctx._sig = createHmac("sha256", secret).update(canonicalJson(ctx)).digest("hex");
  return ctx;
}

const ctx = signUserContext({ id: "u_123" }, process.env.ERA_IDENTITY_SECRET);

const res = await fetch("https://agentifys.ai/v1/credentials", {
  method: "PUT",
  headers: {
    Authorization: "Bearer era_your_project_key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    user_context: ctx,
    name: "github_token",
    value: "ghp_ABCD1234EFGH5678wxyz",
  }),
});
const { name, secret_hint } = await res.json();

The response only ever hands back a masked hint, never the value. The hint is the first eight characters, an ellipsis, and the last four; a value of eight characters or fewer masks to **** entirely.

json
{
  "name": "github_token",
  "secret_hint": "ghp_ABCD...wxyz"
}

List the masked hints or revoke a secret with the same signed identity. On these two the identity travels as a user_context query parameter, URL-encoded JSON, because there is no request body to carry it:

GET/v1/credentials
DELETE/v1/credentials/{name}
Sign a minimal identity for these two calls. The whole user_context goes in the URL, so every field you put in it — including auth_token if you habitually sign one in — lands in your proxy's access log, the server's access log, and any intermediary that records request lines. An { "id": "u_123" } context plus _ts and _sig is all these endpoints read.

Reference it in a tool or MCP token

Once a secret is stored, reference it by name in any tool auth header or MCP connection token with the {{user.creds.<name>}} placeholder. At call time Agentifys swaps the placeholder for the decrypted value. The name in the placeholder must match the name you provisioned, exactly.

A webhook tool has no auth section. Its credentials live in webhook_headers — the same dictionary as every other header — or, less commonly, in webhook_params. Those two are the only fields where a placeholder resolves: webhook_url is used literally, and so are the arguments the model produced.

json
// The tool's webhook_headers. There is no "auth" object.
{
  "Authorization": "Bearer {{user.creds.github_token}}"
}

For a remote MCP server that uses bearer or header auth, put the placeholder in the token field instead, on its own. With bearer auth Agentifys prefixes Bearer  for you; with header auth the resolved value is sent exactly as stored, under the header name you chose (or X-API-Key if you left it blank). Either way, when Alice chats her token is injected; when Bob chats his is. Same tool, per-user credentials.

text
{{user.creds.github_token}}
The two paths fail in opposite directions. If the placeholder does not resolve — a misspelled name, no such credential for this user, an unverified identity — a webhook fails open: the token is replaced with an empty string and the request goes out with Authorization: Bearer and nothing after it, which your API answers 401. An MCP server fails closed: the connection is never opened and the tool returns TOOL_DISABLED with a detail naming the credential it wanted. Check the value you receive server-side rather than assuming a template that was configured is a template that resolved.
To forward a live session token instead of a stored secret, use {{user.auth_token}}. That injects the JWT you passed in signed identity, handy for calling your own API as the current user.
{{user.auth_token}} resolves to an empty string in a scheduled run. The scheduler rebuilds the user from a snapshot of the id plus name, email, role and company — there is no live request to take a token from. Vault credentials are the exception and are hydrated as usual, which is why a tool that has to run unattended should authenticate with {{user.creds.<name>}} rather than a forwarded session token.
The model never sees the secret. It is substituted into the outbound request after the model has decided to call the tool, so it stays out of the prompt and out of the transcript. What is stored and shown alongside it is the masked hint, never the value.