Open Agent Loops

Permissions & Credentials

A short guide — admit, deny, or ask before tool calls run, and compose permissions with credentials, the same way you'd guard a skill's tools.

Some tool calls are risky enough that a human should approve them first. This guide picks up where the multi-turn chat loop leaves off and adds an admission policy: each turn's tool calls pass through the gateToolCalls hook before any of them run, and you allow / deny / ask.

Gating is batteries-included — pair the hook with the shipped permissionGate and InMemoryPermissionStore, no custom gate to write. permissionGate builds the hook from two small seams: a PermissionStore (the configured policy, and where "always" choices are persisted) and an ApprovalPrompter (how you ask the user when the policy is "ask").

Wire both into the chat loop. Here weather runs freely, anything else falls through to "ask", and the prompter reuses the loop's own readline for a y/n:

import {
  runAgent,
  SessionMemoryStore,
  defineTool,
  permissionGate,
  InMemoryPermissionStore,
  PermissionPolicy,
  ApprovalChoice,
} from "@open-agent-loops/core";
import type { ApprovalPrompter } from "@open-agent-loops/core";
import { z } from "zod";
// ...same `model`, `weather` tool, and `render` renderer as the multi-turn chat
// loop, plus its Node `readline` imports (`createInterface`, `input`, `output`).

// A tool worth gating: deploying to production shouldn't run unattended.
const deploy = defineTool({
  name: "deploy",
  description: "Deploy the current build to an environment.",
  parameters: z.object({ env: z.enum(["staging", "production"]) }),
  execute: async ({ env }) => ({ content: `deployed → ${env}` }),
});

const memory = new SessionMemoryStore(); // one store, reused every turn
const sessionId = "chat";                // same id every turn → one conversation
const rl = createInterface({ input, output });

// Config: weather is always allowed; everything else falls through to "ask".
const store = new InMemoryPermissionStore({
  fallback: PermissionPolicy.Ask,
  rules: { weather: PermissionPolicy.Allow },
});

// Prompter: reuse the loop's readline to ask y/n when the policy is "ask".
const prompter: ApprovalPrompter = {
  async ask(batch) {
    const choices: ApprovalChoice[] = [];
    for (const { toolCall, args } of batch) {
      const reply = (
        await rl.question(`\nallow ${toolCall.function.name}(${JSON.stringify(args)})? [y/N] `)
      ).trim().toLowerCase();
      choices.push(reply === "y" ? ApprovalChoice.AllowOnce : ApprovalChoice.DenyOnce);
    }
    return choices;
  },
};

while (true) {
  const prompt = (await rl.question("\nyou › ")).trim();
  if (prompt === "" || prompt === "exit") break;

  process.stdout.write("bot › ");
  await runAgent({
    model,
    memory,
    sessionId,
    prompt,
    tools: [weather, deploy],
    hooks: { gateToolCalls: permissionGate(store, prompter) },
    onEvent: render,
  });
}
rl.close();

Ask about the weather and it just runs; ask it to "deploy to production" and you get a [y/N] prompt before the tool runs. The whole turn's calls arrive together, so the hook runs once per turn — serially, before the parallel execution phase — one round-trip even if the model requested several gated calls, and the prompt never races tool execution. A denied call never runs; it becomes an error tool-result the model can react to.

Return ApprovalChoice.AllowAlways / DenyAlways instead of the "once" variants and permissionGate writes that policy back to the store, so the next turn — or, with a file-backed store, the next run — won't ask again.

Both at Once: A Tool That Needs a Secret and Approval

A real deploy also needs a credential to authenticate — and you don't want the token in the model's view or the transcript. That's a second, independent seam: withCredentials wraps the tool, resolving {{placeholder}} args on the way in and scrubbing the secret out of the result on the way out. It composes with the gate without either knowing about the other — the credential wraps the tool, the permission gates the batch:

import { withCredentials, InMemoryCredentialStore } from "@open-agent-loops/core";

// deploy now authenticates with a token it never sees in the clear: the model
// passes the placeholder `{{deploy_token}}`, resolved only inside execute.
const deploy = defineTool({
  name: "deploy",
  description: "Deploy the current build to an environment.",
  parameters: z.object({
    env: z.enum(["staging", "production"]),
    token: z.string().describe("Auth token — pass the placeholder {{deploy_token}}"),
  }),
  execute: async ({ env, token }) => {
    const res = await fetch(`https://deploy.example.com/${env}`, {
      method: "POST",
      headers: { authorization: `Bearer ${token}` },
    });
    return { content: `deploy → ${env}: ${res.status}` };
  },
});

// Credential seam: where the real secret lives (loaded from env at startup).
const creds = new InMemoryCredentialStore({
  secrets: { deploy_token: process.env.DEPLOY_TOKEN ?? "" },
});

// Same multi-turn chat loop as above — just wrap the tool with its credential;
// the gate is unchanged.
while (true) {
  const prompt = (await rl.question("\nyou › ")).trim();
  if (prompt === "" || prompt === "exit") break;

  process.stdout.write("bot › ");
  await runAgent({
    model,
    memory,
    sessionId,
    prompt,
    tools: [weather, withCredentials(deploy, creds)], // credential: a per-tool wrapper
    hooks: { gateToolCalls: permissionGate(store, prompter) }, // permission: a hook over the batch
    onEvent: render,
  });
}
rl.close();

When the model calls deploy, the gate prompts first (it's still "ask"); on approval, withCredentials swaps {{deploy_token}} for the real token for that one call and scrubs it from the result. Deny it, and the call never runs — it comes back as an error tool-result the model can react to.

Guarding a Skill

A skill — a named bundle of instructions the model pulls in on demand — inherits both guards for free, because a skill runs through tools you have already wired. The typical skill ships no tools of its own: it is pure instructions that drive the shared shell tool. So you wrap that one shell tool with withCredentials and gate it with the same gateToolCalls hook, and every command the skill issues is both credentialed and approved — the skill layer never has to know about either seam.

The runnable examples/guarded-skill-chat/guarded-skill-chat.ts is exactly the loop above plus a SkillRegistry and skillTool: a secret-hello skill tells the model to run SECRET_HELLO_TOKEN={{secret_hello_token}} ./secret-hello "<name>" against a real binary that refuses to run without the credential. Because the gate runs before execute and the credential swap happens inside it, the approval prompt shows you {{secret_hello_token}}never the real secret.

See agent-loop-core/docs/skills.md for the skill seam itself, and examples/secret-hello-skill/ for the same skill without the permission gate.

On this page