OneLinersCommand workbench
Guides
AI/ML / System Administration / Security / Observability & Monitoring

Build a private self-hosted bot with Ollama

Deploy an authenticated loopback service that uses Ollama through its OpenAI-compatible API, exposes only three compiled read-only host probes, records content-free audit evidence, runs under a hardened systemd account, and supports controlled backup, upgrade, rollback, and removal.

210 min11 stepsChanges system stateRevision 1
Save or explore
Save to collectionCreate a collection in the sidebar first.
0 of 11 steps completed
Goal

Operate a private assistant whose model can request only fixed executable-and-argv diagnostics, whose clients authenticate with a revocable secret, whose metrics and audit trail reveal reliability without retaining prompts, and whose service never interprets generated shell text or performs consequential writes.

Supported environments
  • Ollama 0.13.3+
  • Node.js 22.22.2
  • Linux Ubuntu 24.04 LTS or equivalent systemd host
Prerequisites
  • Dedicated host boundary Use a maintained Linux host or VM with console recovery, capacity for the approved model, and no requirement to expose Ollama directly to the public network.
  • Model approval Select a model whose license, provenance, tool-calling behavior, context needs, memory demand, and expected tasks have been reviewed with a synthetic evaluation set.
  • Service identity Reserve an unprivileged ollama-bot account and an owner-only state directory; the bot must not run as root or share write access with model storage.
  • Client authentication plan Generate a random bearer token, store only its SHA-256 digest in the bot environment, distribute the original through an approved secret channel, and define rotation and revocation.
Operating boundary

OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.

Full guide

What you will build

System
  • An authenticated Node service on loopback that talks to a local Ollama model through the documented OpenAI-compatible Chat Completions interface.
  • A compiled tool policy with only uptime, filesystem capacity, and listening TCP probes, each implemented as an absolute executable plus immutable argv with no shell.
  • A hardened systemd unit, content-free Prometheus-style metrics, append-only audit metadata, deterministic policy tests, and explicit remote-gateway boundary.
  • Backup, recovery, upgrade, rollback, token rotation, and teardown procedures that distinguish code, secrets, audit, runtime, and model weights.
Observable outcome
  • Unauthenticated callers cannot reach model or tool execution, and authenticated requests have tools disabled unless they explicitly opt into the read-only set.
  • No model output can create a command line, change argv, select a filesystem path, invoke sudo, execute a write, or add a new tool.
  • Operators can correlate failures and tool use through request IDs and counters without retaining prompt, response, bearer token, or raw diagnostic output.
  • A previous immutable release can be restored with a rotated token and pinned model, while shared Ollama storage is preserved.

Architecture

How the parts fit together

The private service separates identity, inference, execution, and evidence. A client authenticates to a loopback bot using a high-entropy bearer token. The bot validates bounded messages and decides whether the fixed read-only tool definitions are visible. Ollama proposes text or a known tool name through its OpenAI-compatible response. Zod validates the name and rejects every argument; a fixed spawn policy runs one absolute executable without a shell. Results return separately for human review. Content-free audit and metrics describe operation, while systemd confines filesystem and privilege access. Remote access, if ever required, terminates at a different authenticated gateway and never publishes Ollama directly.

Authenticated bot APIValidates token digest, body size, message schema, tool opt-in, model response shape, and no-store output behavior.
Ollama loopback serverHosts the approved local model and implements the documented OpenAI-compatible model and chat endpoints.
Compiled tool executorMaps three names to fixed executable-and-argv definitions, enforces time and output limits, and rejects all parameters.
systemd sandboxRuns the bot unprivileged with read-only system paths, no home or device access, and one writable audit directory.
Operational evidenceExposes content-free counters and append-only request metadata for incident correlation and recovery.
  1. The authenticated client submits at most twenty bounded user and assistant messages and chooses whether read-only probes are available.
  2. The bot calls the selected local model at 127.0.0.1:11434 using the OpenAI-compatible client and no cloud credential.
  3. If the model requests a tool, strict validation accepts only a compiled name with no arguments; every other request fails closed.
  4. The executor starts the fixed process with shell false, minimal environment, ignored stdin, fixed working directory, timeout, and output cap.
  5. The service returns unverified draft text and bounded evidence separately, records content-free audit metadata, and performs no second autonomous action.
  6. Operators inspect metrics, journal events, release checksums, model tag, and audit IDs during acceptance, upgrade, or recovery.

Assumptions

  • The host is maintained, console recovery exists, and the service is not treated as a boundary against root or a fully compromised operating system.
  • The selected model is approved, locally available, capable enough for the evaluated tasks, and not trusted to authorize or define execution.
  • Initial clients are local or reach the service through a separately reviewed private gateway; neither Ollama nor the bot is publicly exposed.
  • A high-entropy machine-generated token is used. Human passwords are not hashed with a single fast SHA-256 operation.
  • The three probes are acceptable under local privacy policy and their raw output is returned only to an authorized requesting client.
  • No production requirement depends on autonomous repair, arbitrary paths, sudo, external network tools, or silent consequential changes.

Key concepts

OpenAI-compatible client
A familiar SDK configured with a different base URL. Ollama supports documented portions of the API; unsupported fields and semantics must not be assumed.
Compiled allowlist
A source-controlled mapping from logical tool names to literal executable paths and argument arrays. The model cannot add, edit, or parameterize entries.
shell false
The process starts an executable directly, so pipes, redirects, substitutions, globbing, separators, and shell built-ins in model text have no interpretation.
Consequential write
An action that changes files, services, accounts, packages, network policy, processes, external systems, or durable data and therefore needs a separate proposal and approval path.
Content-free audit
Operational evidence containing IDs, times, result classes, counts, and approved tool names without prompt, response, secret, or raw host output.
High-entropy bearer token
A randomly generated secret with enough unpredictable bits that storing a fast digest is suitable; this is different from a memorable password.
Fail closed
Unknown tool names, extra arguments, malformed JSON, timeouts, and oversized output produce an error without executing an alternative or broadening permissions.
Immutable release
A versioned application directory whose code is not edited in place; activation changes one reviewed pointer and rollback restores the prior directory.

Before you copy

Values used in this guide

{{ollamaModel}}

Exact locally pulled model tag approved for this service and recorded in the recovery manifest.

Example: gpt-oss:20b
{{botToken}}secret

Random client bearer token distributed through an approved secret channel; only its SHA-256 digest resides in the service environment.

Example: generated with at least 32 random bytes
{{backupDirectory}}

Resolved encrypted owner-only destination for the closed audit snapshot, checksums, and recovery manifest.

Example: /mnt/encrypted/ollama-operator/2026-07-29

Security and production boundaries

  • Ollama's OpenAI-compatible api_key placeholder is documented as ignored; do not mistake it for server authentication.
  • Keep both Ollama and the bot on loopback. Remote access belongs behind TLS, individual identity, authorization, request limits, and separate review.
  • Never pass model text to exec, spawn through a shell, sudo, a script interpreter, an SQL engine, or a path join used for writes.
  • The model may suggest a valid tool incorrectly. Runtime allowlisting prevents unsupported actions but does not make every allowed probe contextually appropriate.
  • Raw diagnostics can reveal storage names, capacity, listeners, and host timing. Return them only to authenticated users and do not retain them in general audit logs.
  • Metrics must avoid high-cardinality or private labels. Request IDs, status classes, tool names, and aggregate durations are sufficient for initial operation.
  • A bearer token is replayable. Rotate it after suspected exposure and prefer per-user gateway identity for teams.
  • Do not run as root. The initial tools require no privilege, home directory, device, package, service-management, or general write access.
  • No automated model response should trigger a consequential write. Future write features require human approval over an exact structured proposal and verified rollback.
  • Model upgrades are application changes: repeat synthetic evaluations, tool-call rejection tests, latency checks, and recovery rehearsal.

Stop before continuing if

  • Stop if any requested tool requires a shell string, user-supplied argv, arbitrary path, sudo, outbound network, package change, process kill, or file write.
  • Stop if either service listens on a wildcard or public address without the approved gateway architecture.
  • Stop if prompts, responses, secrets, or raw tool output appear in metrics, audit, systemd status, reverse-proxy logs, or support bundles.
  • Stop if an unauthenticated request can consume model or tool resources.
  • Stop if the bot runs as root or needs broad write access to solve a local permission problem.
  • Stop if a model change has not passed the negative tool-policy suite and representative synthetic evaluations.
  • Stop before backup or teardown if a target path is unresolved, unencrypted, shared, or contains the only audit evidence.
  • Stop if operators describe any generated answer as verified merely because inference and tools ran locally.
01

instruction

Write the trust and action policy

Classify users, prompts, host evidence, audit metadata, model output, and secrets before installation. State that Ollama and the bot listen on loopback, that a separate authenticated reverse proxy is required for remote clients, and that the initial tool set is read-only. Name every permitted executable and exact argument list. Explicitly prohibit arbitrary shell strings, user-supplied paths, sudo, package changes, file writes, process termination, outbound probes, and autonomous retries. A model may request evidence; application code decides whether the request matches the compiled policy.

Why this step matters

Tool safety comes from deterministic runtime enforcement and service isolation, not from a prompt asking a probabilistic model to behave carefully.

What to understand

Read-only commands can still reveal sensitive topology, usernames, mounts, or listeners, so authorization and output handling remain necessary.

A cloud-style API shape does not make the local server multi-tenant or safely exposed; authentication belongs in the application or reverse proxy.

Any future write tool needs a proposal object, exact diff, authenticated human approval, idempotency, audit, and rollback rather than reuse of this read-only executor.

System changes

  • Creates a reviewed policy document and executable allowlist specification; it changes no host service.
Example output / evidence
Approved policy: loopback only; authenticated clients; tools limited to /usr/bin/uptime, /usr/bin/df -P -k, and /usr/bin/ss -lnt; no shell, paths, sudo, network probes, or writes.

Checkpoint: Policy is mechanically implementable

Continue whenEvery allowed probe maps to one absolute executable and immutable argv, while every write and network probe is explicitly absent.

Stop whenStop if requirements include free-form commands, root privileges, silent writes, unrestricted paths, or public exposure without a separate access design.

Security notes

  • Never expose raw model tool arguments directly to spawn, exec, a shell, or a templating layer.

Alternatives

  • Offer operator-run copyable diagnostics when even read-only service probes are inappropriate.

Stop conditions

  • Do not treat model confidence as authorization.
02

command

Install and bind Ollama to loopback

caution

Install Ollama from its official Linux distribution, keep the service account and systemd unit, and configure OLLAMA_HOST for 127.0.0.1:11434. Pull the exact approved model tag while online, then verify the listener and local model inventory. Do not place authentication responsibility on Ollama's ignored OpenAI-compatible api_key field; this design authenticates callers at the bot boundary and prevents direct remote access to the Ollama port.

Why this step matters

A private bot needs a reproducible local model endpoint and exact model tag before application behavior, resource budgets, and recovery can be evaluated.

What to understand

The systemd override should contain Environment="OLLAMA_HOST=127.0.0.1:11434" and no wildcard address.

The OpenAI-compatible API requires an api_key in many client libraries, but Ollama documents that the placeholder is ignored.

Pulling changes persistent model storage and may use substantial bandwidth and disk, so capacity and license approval precede the command.

System changes

  • Installs or updates Ollama, writes a systemd override, restarts its service, and downloads the selected model into Ollama-managed storage.

Syntax explained

systemctl edit
Creates a managed override rather than modifying the vendor service unit in place.
ollama pull
Downloads the exact model tag and changes persistent Ollama model storage.
/v1/models
Exercises Ollama's documented OpenAI-compatible model-list endpoint locally.
Command
Fill variables0/1 ready

Values stay on this page and are never sent or saved.

sudo systemctl edit ollama.service && sudo systemctl daemon-reload && sudo systemctl restart ollama && ollama pull {{ollamaModel}} && curl --fail --silent --max-time 5 http://127.0.0.1:11434/v1/models
Example output / evidence
{"object":"list","data":[{"id":"gpt-oss:20b","object":"model","owned_by":"library"}]}

Checkpoint: Ollama is private and model-ready

ss -lntp | grep 127.0.0.1:11434 && ollama list

Continue whenOnly a loopback listener appears and the exact approved model tag is listed locally.

Stop whenStop if Ollama binds to 0.0.0.0, a LAN address, or the model provenance and storage impact are unapproved.

Alternatives

  • Use a manually installed pinned Ollama version when organizational release controls require exact binaries.

Stop conditions

  • Do not run the install script without reviewing the current official instructions and change window.
03

config

Scaffold the pinned TypeScript service

Create an owner-controlled source tree and save package.json with exact runtime versions. The service uses the official OpenAI JavaScript client against Ollama's loopback base URL, Zod for runtime validation, Node HTTP and crypto primitives, and no web framework. Pinning is not a substitute for lockfile review, but it keeps API behavior, compilation, and recovery evidence explicit.

Why this step matters

A small pinned dependency surface is easier to audit, package, reproduce, and roll back than an application assembled from unbounded global tooling.

What to understand

The engine field records the supported Node line; production should install dependencies from a reviewed lockfile with npm ci.

The OpenAI client is configured later with an Ollama baseURL and placeholder local apiKey, never a cloud key.

Build and state directories remain distinct so releases can be immutable and audit state can survive rollback.

System changes

  • Creates package metadata and a lockfile under the dedicated application directory after npm install.

Syntax explained

private: true
Prevents accidental publication of the service package to the npm registry.
npm ci
Installs the exact reviewed lockfile graph and fails rather than rewriting dependency resolution.
File /opt/ollama-operator/package.json
Configuration
{
  "name": "private-ollama-operator-bot",
  "private": true,
  "type": "module",
  "engines": { "node": "22.22.2" },
  "scripts": {
    "start": "tsx src/server.ts",
    "test": "node --import tsx --test test/*.test.ts",
    "check": "tsc --noEmit"
  },
  "dependencies": {
    "openai": "7.1.0",
    "zod": "4.4.3"
  },
  "devDependencies": {
    "@types/node": "22.19.19",
    "tsx": "4.23.1",
    "typescript": "5.9.3"
  }
}
Example output / evidence
added 9 packages, and audited 10 packages in 2s
found 0 vulnerabilities

Checkpoint: Dependency graph is reproducible

cd /opt/ollama-operator && npm install --package-lock-only && npm ci && npm run check

Continue whenThe lockfile is created, exact dependencies install, and strict TypeScript compilation succeeds.

Stop whenStop if installation runs as root into an uncontrolled directory or the lockfile includes an unreviewed registry or lifecycle script.

Alternatives

  • Build in CI and deploy a signed immutable artifact instead of installing dependencies on the service host.

Stop conditions

  • Do not store bearer tokens or prompts in package metadata, lockfiles, build logs, or source maps.
04

config

Implement the compiled read-only tool allowlist

Save src/tools.ts. Each logical tool maps to a literal absolute executable and literal argv. Runtime validation accepts only three names and no arguments. Node spawn runs with shell false, a minimal environment, fixed working directory, ignored stdin, a ten-second timeout, and independent 64 KiB stdout and stderr caps. Model-generated text cannot select a path, add a flag, pipe output, redirect a file, expand a variable, or invoke another program.

Why this step matters

The executor is the principal safety boundary, so its accepted schema and process parameters must be narrower than the model's expressive output.

What to understand

Absolute executable paths avoid PATH substitution even though a minimal PATH is supplied for command internals.

Output caps and timeout turn surprising host state into a controlled error rather than memory exhaustion or a stuck request.

The listening probe omits process ownership because that evidence is often more sensitive and is unnecessary for the initial use case.

System changes

  • Creates a source module; running a tool later starts only the selected fixed read-only process as the unprivileged service account.

Syntax explained

spawn(executable, args)
Runs one reviewed executable with a separate immutable argument vector.
shell: false
Prevents shell metacharacter, expansion, pipeline, and redirection interpretation.
kill SIGKILL after 10 seconds
Applies a hard runtime ceiling to every probe.
File /opt/ollama-operator/src/tools.ts
Configuration
import { spawn } from "node:child_process";
import { z } from "zod";

const tools = {
  host_uptime: { executable: "/usr/bin/uptime", args: [] },
  disk_usage: { executable: "/usr/bin/df", args: ["-P", "-k"] },
  listening_tcp: { executable: "/usr/bin/ss", args: ["-lnt"] }
} as const;

export type ToolName = keyof typeof tools;

export const toolRequest = z.object({
  name: z.enum(["host_uptime", "disk_usage", "listening_tcp"])
}).strict();

export async function runReadOnlyTool(
  name: ToolName
): Promise<{ name: ToolName; exitCode: number; stdout: string; stderr: string }> {
  const definition = tools[name];
  return await new Promise((resolve, reject) => {
    const child = spawn(definition.executable, definition.args, {
      shell: false,
      env: { PATH: "/usr/bin:/bin", LANG: "C.UTF-8" },
      cwd: "/var/empty",
      stdio: ["ignore", "pipe", "pipe"]
    });
    const limit = 65_536;
    let stdout = Buffer.alloc(0);
    let stderr = Buffer.alloc(0);
    let exceeded = false;
    const append = (current: Buffer, chunk: Buffer) => {
      const next = Buffer.concat([current, chunk]);
      if (next.length > limit) {
        exceeded = true;
        child.kill("SIGKILL");
        return next.subarray(0, limit);
      }
      return next;
    };
    child.stdout.on("data", (chunk: Buffer) => { stdout = append(stdout, chunk); });
    child.stderr.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); });
    const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
    child.once("error", reject);
    child.once("close", (code, signal) => {
      clearTimeout(timer);
      if (signal || exceeded) {
        reject(new Error(exceeded ? "tool_output_limit" : "tool_timeout"));
        return;
      }
      resolve({
        name,
        exitCode: code ?? 1,
        stdout: stdout.toString("utf8"),
        stderr: stderr.toString("utf8")
      });
    });
  });
}

export const openAiToolDefinitions = [
  {
    type: "function" as const,
    function: {
      name: "host_uptime",
      description: "Read the host uptime and load averages.",
      parameters: { type: "object", properties: {}, additionalProperties: false }
    }
  },
  {
    type: "function" as const,
    function: {
      name: "disk_usage",
      description: "Read POSIX filesystem capacity in fixed kilobyte units.",
      parameters: { type: "object", properties: {}, additionalProperties: false }
    }
  },
  {
    type: "function" as const,
    function: {
      name: "listening_tcp",
      description: "Read listening TCP socket addresses without process details.",
      parameters: { type: "object", properties: {}, additionalProperties: false }
    }
  }
];
Example output / evidence
Compiled 3 read-only tools: host_uptime, disk_usage, listening_tcp; shell execution disabled.

Checkpoint: Unknown names and arguments fail closed

cd /opt/ollama-operator && npm test -- --test-name-pattern='allowlist'

Continue whenRequests for shell or extra path arguments are rejected before any process starts.

Stop whenStop if code uses exec, shell true, a command string, user-provided argv, inherited secrets, or an unbounded output collector.

Security notes

  • Tool output is untrusted data that can contain terminal control sequences or misleading text; render or encode it safely.

Alternatives

  • Read equivalent metrics from a dedicated telemetry collector rather than spawning host commands.

Stop conditions

  • Never add a generic run_command tool.
05

config

Add authentication, model calls, audit, and metrics

Save src/server.ts. The service binds to a literal loopback host, validates a bearer token using a stored SHA-256 digest and timing-safe comparison, limits request bytes and message history, and calls Ollama through the OpenAI-compatible client. Tools are absent unless the authenticated user opts into read-only probes. The response returns draft text and evidence separately and intentionally does not run an autonomous second tool loop. Audit records IDs, outcome, tool names, and message count but never prompt, response, token, or raw tool output.

Why this step matters

Authentication, bounded parsing, tool authorization, privacy-preserving audit, and model invocation must meet in one request boundary before any local evidence is collected.

What to understand

The hash is adequate for a high-entropy random token, not for a human password; passwords require a slow password-hashing scheme.

Metrics have no user, prompt, path, or model-output labels and remain on loopback with the application.

Returning tool results separately lets a human inspect evidence and prevents an unconstrained model-tool-model loop from multiplying calls.

System changes

  • Creates the service request handler and appends content-free NDJSON records under the dedicated state path during authenticated requests.

Syntax explained

timingSafeEqual
Compares fixed-length token digests without ordinary early-exit string comparison.
tool_choice: none
Disables model tool requests unless the authenticated caller explicitly enables the read-only set.
cache-control: no-store
Tells intermediaries not to cache bot responses.
File /opt/ollama-operator/src/server.ts
Configuration
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { appendFile } from "node:fs/promises";
import OpenAI from "openai";
import { z } from "zod";
import { openAiToolDefinitions, runReadOnlyTool, toolRequest } from "./tools.js";

const env = z.object({
  BOT_HOST: z.literal("127.0.0.1").default("127.0.0.1"),
  BOT_PORT: z.coerce.number().int().min(1024).max(65535).default(8088),
  BOT_TOKEN_SHA256: z.string().regex(/^[a-f0-9]{64}$/),
  OLLAMA_MODEL: z.string().min(1).max(120),
  OLLAMA_BASE_URL: z.literal("http://127.0.0.1:11434/v1").default("http://127.0.0.1:11434/v1"),
  AUDIT_FILE: z.string().default("/var/lib/ollama-operator/audit.ndjson")
}).parse(process.env);

const openai = new OpenAI({ baseURL: env.OLLAMA_BASE_URL, apiKey: "ollama" });
const requestSchema = z.object({
  messages: z.array(z.object({
    role: z.enum(["user", "assistant"]),
    content: z.string().min(1).max(16_384)
  })).min(1).max(20),
  allowReadOnlyTools: z.boolean().default(false)
}).strict();

const counters = { requests: 0, rejected: 0, tools: 0, failures: 0 };

function authenticate(header: string | undefined): boolean {
  if (!header?.startsWith("Bearer ")) return false;
  const actual = Buffer.from(createHash("sha256").update(header.slice(7)).digest("hex"));
  const expected = Buffer.from(env.BOT_TOKEN_SHA256);
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

async function audit(event: Record<string, unknown>): Promise<void> {
  await appendFile(env.AUDIT_FILE, JSON.stringify({
    timestamp: new Date().toISOString(),
    ...event
  }) + "\n", { encoding: "utf8", mode: 0o600 });
}

async function readBody(request: import("node:http").IncomingMessage): Promise<unknown> {
  const chunks: Buffer[] = [];
  let size = 0;
  for await (const chunk of request) {
    const bytes = Buffer.from(chunk);
    size += bytes.length;
    if (size > 65_536) throw new Error("request_too_large");
    chunks.push(bytes);
  }
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}

const server = createServer(async (request, response) => {
  const requestId = randomUUID();
  if (request.url === "/healthz" && request.method === "GET") {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ status: "ok", model: env.OLLAMA_MODEL }));
    return;
  }
  if (request.url === "/metrics" && request.method === "GET") {
    response.writeHead(200, { "content-type": "text/plain; version=0.0.4" });
    response.end(Object.entries(counters)
      .map(([name, value]) => "ollama_operator_" + name + "_total " + value)
      .join("\n") + "\n");
    return;
  }
  if (request.url !== "/v1/chat" || request.method !== "POST") {
    response.writeHead(404).end();
    return;
  }
  if (!authenticate(request.headers.authorization)) {
    counters.rejected++;
    response.writeHead(401, { "content-type": "application/json" });
    response.end(JSON.stringify({ error: "unauthorized", requestId }));
    return;
  }
  counters.requests++;
  try {
    const input = requestSchema.parse(await readBody(request));
    const completion = await openai.chat.completions.create({
      model: env.OLLAMA_MODEL,
      messages: [
        {
          role: "system",
          content: "You are a private operations assistant. Use only supplied read-only tools. Never claim to execute writes. Explain uncertainty."
        },
        ...input.messages
      ],
      tools: input.allowReadOnlyTools ? openAiToolDefinitions : undefined,
      tool_choice: input.allowReadOnlyTools ? "auto" : "none",
      temperature: 0.2
    });
    const message = completion.choices[0]?.message;
    const toolResults: Array<Record<string, unknown>> = [];
    for (const call of message?.tool_calls ?? []) {
      if (call.type !== "function") continue;
      const parsed = toolRequest.parse({
        name: call.function.name,
        ...JSON.parse(call.function.arguments || "{}")
      });
      const result = await runReadOnlyTool(parsed.name);
      counters.tools++;
      toolResults.push({
        toolCallId: call.id,
        name: result.name,
        exitCode: result.exitCode,
        stdout: result.stdout,
        stderr: result.stderr
      });
    }
    await audit({
      requestId,
      outcome: "ok",
      toolNames: toolResults.map((item) => item.name),
      messageCount: input.messages.length
    });
    response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
    response.end(JSON.stringify({
      requestId,
      draft: message?.content ?? "",
      toolResults,
      notice: "Model output is unverified. No write action was executed."
    }));
  } catch (error) {
    counters.failures++;
    await audit({ requestId, outcome: "error", errorClass: error instanceof z.ZodError ? "validation" : "runtime" });
    response.writeHead(400, { "content-type": "application/json" });
    response.end(JSON.stringify({ error: "request_failed", requestId }));
  }
});

server.listen(env.BOT_PORT, env.BOT_HOST, () => {
  console.log(JSON.stringify({ event: "listening", host: env.BOT_HOST, port: env.BOT_PORT }));
});
Example output / evidence
{"event":"listening","host":"127.0.0.1","port":8088}

Checkpoint: Unauthenticated and oversized requests fail

curl --silent --output /dev/null --write-out '%{http_code}\n' -X POST http://127.0.0.1:8088/v1/chat -H 'Content-Type: application/json' -d '{"messages":[{"role":"user","content":"test"}]}'

Continue whenThe service returns 401 without contacting Ollama or running a host probe.

Stop whenStop if /v1/chat is externally reachable, accepts an unhashed static password, logs content, or runs tools without the explicit request flag.

Security notes

  • A SHA-256 token digest is safe only for a randomly generated token with sufficient entropy.

Alternatives

  • Terminate TLS and use OIDC at a reviewed reverse proxy for remote multi-user access.

Stop conditions

  • Do not bind BOT_HOST to a wildcard to solve remote access.
06

config

Run under a hardened systemd service

Create a dedicated system user, owner-only state directory, root-owned application release, secret environment file, and this service unit. The sandbox denies home access, device access, and writes outside the audit directory. The service can make only local IPv4, IPv6, and Unix connections required for Ollama and its listener. Generate the random bearer token outside the unit, store only its digest in the environment, and deliver the original to clients through a separate approved secret channel.

Why this step matters

An unprivileged identity and restrictive service sandbox contain application defects and tool behavior even when model output or host data is adversarial.

What to understand

ProtectSystem strict makes the operating-system tree read-only while ReadWritePaths grants one explicit audit directory.

ProtectHome prevents probes and code from reading user home directories; the three current tools do not require that access.

The environment file must be root-readable only and contains the token digest, model tag, and fixed paths but not the original client token.

System changes

  • Creates the ollama-bot account and state directory, installs a systemd unit, reads a root-owned environment file, and starts the service.

Syntax explained

NoNewPrivileges=yes
Prevents the service and children from gaining privileges through exec transitions.
ProtectSystem=strict
Makes filesystem locations read-only except explicit writable paths.
UMask=0077
Creates audit and runtime files accessible only to their owner by default.
File /etc/systemd/system/ollama-operator.service
Configuration
[Unit]
Description=Private Ollama operator bot
After=network-online.target ollama.service
Requires=ollama.service

[Service]
Type=simple
User=ollama-bot
Group=ollama-bot
WorkingDirectory=/opt/ollama-operator
EnvironmentFile=/etc/ollama-operator/environment
ExecStart=/usr/bin/node --enable-source-maps /opt/ollama-operator/dist/server.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/ollama-operator
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
UMask=0077

[Install]
WantedBy=multi-user.target
Example output / evidence
Active: active (running)
Main PID: 28417 (node)
Tasks: 11
Memory: 58.4M

Checkpoint: Sandboxed service starts on loopback

systemctl status ollama-operator --no-pager && ss -lntp | grep 127.0.0.1:8088

Continue whenThe service is active as ollama-bot and only the loopback bot listener is visible.

Stop whenStop if the unit runs as root, writes broadly, reads home directories, inherits a cloud key, or publishes a wildcard listener.

Alternatives

  • Run the same immutable service in a rootless container with equivalent loopback and volume boundaries.

Stop conditions

  • Do not weaken the entire sandbox to fix one missing audit directory permission.
07

config

Test the policy and one fixed probe

Save the Node test suite. It proves that invented tool names and additional arguments fail validation, then executes the fixed disk probe and checks its identity, exit status, recognizable header, and output ceiling. Add request-level tests with a fake OpenAI client in CI, plus a local acceptance test against the approved model. Never use production prompts, a cloud API key, mutable system commands, or destructive fixtures.

Why this step matters

Executable policy needs negative tests that demonstrate what cannot run, not only a happy path showing one approved diagnostic succeeds.

What to understand

Strict object validation rejects extra fields, closing the common path where a safe tool name carries an unsafe path or flag.

The integration test runs as the service identity on a prepared host because permissions and utility output vary across environments.

Model behavior is evaluated separately; a model failing to request a tool must never cause the runtime to relax validation.

System changes

  • Creates test code; the positive test executes only /usr/bin/df with fixed read-only arguments and captures bounded output.

Syntax explained

assert.throws
Proves invalid names and unexpected arguments fail before execution.
assert.ok length <= 65536
Confirms captured diagnostic output respects the documented ceiling.
File /opt/ollama-operator/test/tools.test.ts
Configuration
import assert from "node:assert/strict";
import test from "node:test";
import { toolRequest, runReadOnlyTool } from "../src/tools.js";

test("rejects every tool outside the compiled allowlist", () => {
  assert.throws(() => toolRequest.parse({ name: "shell", command: "id" }));
  assert.throws(() => toolRequest.parse({ name: "disk_usage", path: "/" }));
});

test("runs the disk probe with fixed executable and argv", async () => {
  const result = await runReadOnlyTool("disk_usage");
  assert.equal(result.name, "disk_usage");
  assert.equal(result.exitCode, 0);
  assert.match(result.stdout, /Filesystem/);
  assert.ok(result.stdout.length <= 65_536);
});
Example output / evidence
✔ rejects every tool outside the compiled allowlist
✔ runs the disk probe with fixed executable and argv
ℹ tests 2
ℹ pass 2

Checkpoint: Tool policy tests pass

cd /opt/ollama-operator && npm test

Continue whenBoth allowlist rejection and fixed disk probe tests pass without a shell, root access, model, or network call.

Stop whenStop if tests mock away the process boundary entirely or rely on a production token and real prompt history.

Alternatives

  • Use a fixture executable in CI while retaining one host acceptance test for actual utility paths.

Stop conditions

  • Do not make tests pass by adding model-proposed names to the allowlist automatically.
08

verification

Run authenticated acceptance and observability checks

caution

Send one synthetic chat with tools disabled, one with read-only tools allowed, one invalid token, one oversized body, and one invented tool request through a controlled model fixture. Check health, metrics, audit NDJSON, systemd journal, Ollama journal, process identity, listener scope, latency, and memory. Evidence should identify request IDs and status classes without transcript content. A successful model answer is not sufficient acceptance if the service boundary or audit leaks data.

Why this step matters

Acceptance must prove authentication, no-tool default, bounded tool execution, private observability, and failure behavior on the deployed service rather than trusting code review alone.

What to understand

The test token is supplied through an environment or secret client and must not remain in shell history or screenshots.

Metrics establish request and failure counts but deliberately cannot reconstruct the prompt or response.

Tool evidence may contain sensitive host state; client authorization and retention policy apply even though audit omits raw output.

System changes

  • Reads host state through one approved uptime probe and appends a content-free audit event for the authenticated synthetic request.

Syntax explained

allowReadOnlyTools: true
Explicitly makes the three compiled diagnostic definitions available for this request only.
--max-time 30
Bounds the client wait during acceptance.
requestId
Correlates client error, audit, and service logs without using private prompt text.
Command
Fill variables0/1 ready

Values stay on this page and are never sent or saved.

curl --fail --silent --max-time 30 -X POST http://127.0.0.1:8088/v1/chat -H "Authorization: Bearer {{botToken}}" -H 'Content-Type: application/json' -d '{"messages":[{"role":"user","content":"Report host uptime using approved read-only evidence."}],"allowReadOnlyTools":true}'
Example output / evidence
{"requestId":"936da82d-4809-4ec7-9556-1648c75051bd","draft":"I can inspect the approved uptime evidence.","toolResults":[{"name":"host_uptime","exitCode":0,"stdout":" 10:20:02 up 8 days,  2:14,  1 user,  load average: 0.11, 0.09, 0.06\n","stderr":""}],"notice":"Model output is unverified. No write action was executed."}

Checkpoint: Private evidence path works

curl --silent http://127.0.0.1:8088/metrics && tail -n 3 /var/lib/ollama-operator/audit.ndjson

Continue whenCounters increase and audit shows request ID, outcome, tool name, and message count without prompt, response, token, or stdout.

Stop whenStop release if metrics or audit contains content, invalid callers reach Ollama, or any tool outside the compiled list starts.

Security notes

  • Use a synthetic request because terminal history and acceptance artifacts may be retained.

Alternatives

  • Keep tool use entirely disabled and expose the same diagnostics through a separate operator-only interface.

Stop conditions

  • Do not publish raw /metrics or audit files through the remote client proxy.
09

warning

Expose remote access only through a reviewed gateway

The service is complete on loopback. If remote clients are required, place a separately managed TLS gateway on the intended private network, authenticate individual users with OIDC or mutual TLS, authorize the bot route, apply request and concurrency limits, preserve no-store headers, and forward to 127.0.0.1:8088. Do not publish Ollama's 11434 port. A shared bearer token may be acceptable for one local operator, but it lacks user-level revocation and attribution for a team.

Why this step matters

Network exposure changes the threat model from same-host application authentication to multi-user transport, identity, authorization, abuse control, and incident response.

What to understand

TLS termination must validate hostnames and current certificates; an internal network alone does not protect prompt confidentiality.

Per-user identity enables revocation and rate limits without sharing the underlying bot token.

The gateway should not buffer or log request bodies, authorization headers, model output, or tool evidence by default.

System changes

  • May add a separately reviewed gateway configuration and identity-provider registration; does not change either loopback service listener.
Example output / evidence
Gateway review: TLS valid; OIDC subject required; /v1/chat rate limited; upstream 127.0.0.1:8088; ports 8088 and 11434 remain unreachable from the client network.

Checkpoint: Remote boundary is independently reviewed

Continue whenOnly the gateway is remotely reachable, every user authenticates individually, rate limits apply, and direct Ollama and bot ports remain loopback-only.

Stop whenStop if the proposed solution is changing OLLAMA_HOST or BOT_HOST to 0.0.0.0, sharing one token broadly, or terminating TLS without identity.

Security notes

  • Remote access should be a separate change with its own tests, rollback, certificate ownership, and incident runbook.

Alternatives

  • Use SSH local port forwarding for a small administrator group when policy permits and user identity already exists in SSH.

Stop conditions

  • Do not publish /healthz or /metrics through the public gateway without a demonstrated operational need.
10

command

Back up audit state and recovery metadata

caution

Stop the bot to create a consistent audit snapshot, copy the NDJSON file and a manifest containing package checksum, Node version, Ollama version, exact model tag, service unit checksum, environment keys without secret values, and review date to an encrypted destination. The original token is not backed up with application state; recovery generates and distributes a new one. Model weights may be re-pulled by exact approved tag unless offline recovery policy requires a separately governed model archive.

Why this step matters

A recoverable private service needs immutable code and configuration evidence plus the minimal approved audit state, while secrets and reproducible model weights follow separate custody rules.

What to understand

Stopping avoids a partially appended NDJSON line and makes the backup boundary easy to explain.

Checksums detect transfer corruption but should be authenticated through release signing or a trusted configuration repository.

Restore rehearsal must occur on an isolated host and must generate a new token rather than reviving an unknown old credential.

System changes

  • Briefly stops the bot, creates an owner-only backup directory, copies audit state and checksums, then starts the service again.

Syntax explained

systemctl stop/start
Creates a clear audit-file consistency boundary and then restores service.
cp --preserve
Retains restrictive permissions and timestamps for recovery evidence.
SHA256SUMS
Records code, unit, and audit digests for later integrity checks.
Command
Fill variables0/1 ready

Values stay on this page and are never sent or saved.

sudo systemctl stop ollama-operator && sudo install -d -m 0700 "{{backupDirectory}}" && sudo cp --preserve=mode,timestamps /var/lib/ollama-operator/audit.ndjson "{{backupDirectory}}/audit.ndjson" && sudo sha256sum /opt/ollama-operator/dist/server.js /etc/systemd/system/ollama-operator.service "{{backupDirectory}}/audit.ndjson" | sudo tee "{{backupDirectory}}/SHA256SUMS" >/dev/null && sudo systemctl start ollama-operator
Example output / evidence
ollama-operator.service stopped
3 files recorded in SHA256SUMS
ollama-operator.service active

Checkpoint: Recovery bundle is complete but secret-free

sudo find "{{backupDirectory}}" -maxdepth 1 -type f -printf '%m %f %s bytes\n'

Continue whenOnly audit.ndjson, SHA256SUMS, and approved recovery metadata appear; no bearer token, prompt, response, stdout, or model weight is included.

Stop whenStop if the destination is unencrypted, broadly readable, or the bot cannot be safely interrupted.

Alternatives

  • Rotate audit files and copy a closed segment without service downtime once rotation is implemented and tested.

Stop conditions

  • Do not include the original client token in the same recovery archive.
11

warning

Upgrade, recover, and tear down without broad deletion

Stage every application revision under a new immutable release directory, run compilation and tests, verify the model tag and Ollama API behavior, then switch the service target and observe synthetic traffic. Keep the previous directory, unit, environment, and audit snapshot until acceptance. Roll back by restoring the prior target and restarting. For teardown, disable the bot, revoke its token, archive or delete audit state by policy, remove the dedicated account and app files, and make a separate decision about Ollama and model weights shared by other workloads.

Why this step matters

Code, credentials, audit records, Ollama runtime, and model weights have different retention and ownership boundaries, so upgrade and removal must treat them independently.

What to understand

A model tag can move; record a content identifier or release evidence when the distribution exposes one and rerun evaluation after any model change.

Never run database-like in-place mutations against the audit file during upgrade; append-only state can be preserved across release rollback.

Removing the bot does not authorize deleting /usr/share/ollama or disabling Ollama if another approved application depends on it.

System changes

  • May change one service release pointer, rotate a bearer token, stop and disable the bot unit, or remove exact dedicated files after explicit approval.
Example output / evidence
Release switched to 2026.07.29-1; prior release retained; synthetic authentication and tool checks passed; audit snapshot verified; model storage unchanged.

Checkpoint: Previous release remains recoverable

Continue whenThe prior checksum-verified release and audit snapshot remain intact until the new service passes authentication, tool-policy, metrics, and failure acceptance.

Stop whenStop if cleanup targets use unresolved variables, recursive home paths, shared Ollama storage, or the only audit copy.

Security notes

  • Rotate the token after rollback when the failed release or troubleshooting process may have exposed it.

Alternatives

  • Keep a disabled quarantine copy of the service for a defined evidence-retention period before deletion.

Stop conditions

  • Never use an AI-generated cleanup command without independently resolving and reviewing every target.

Finish line

Verification checklist

Private listenersss -lntp | grep -E '127\.0\.0\.1:(11434|8088)'Ollama and the bot listen only on loopback; no wildcard or public socket exists.
Authentication boundarycurl --silent --output /dev/null --write-out '%{http_code}\n' -X POST http://127.0.0.1:8088/v1/chatThe unauthenticated request returns 401 without a model or tool call.
Tool policycd /opt/ollama-operator && npm testUnknown names and extra arguments fail, while one fixed read-only probe respects timeout and output limits.
Content-free observabilitycurl --silent http://127.0.0.1:8088/metrics && tail -n 5 /var/lib/ollama-operator/audit.ndjsonMetrics and audit contain counts, IDs, outcomes, and approved tool names but no prompt, response, secret, or raw tool output.

Recovery guidance

Common problems and safe checks

Ollama is unreachable on loopback.

Likely causeThe service is stopped, bound elsewhere, or blocked by a mismatched service override.

Safe checks
  • Check systemctl status ollama without changing it.
  • Request /api/tags from 127.0.0.1 with a short timeout.

ResolutionRestore OLLAMA_HOST to loopback, reload the service, and verify the listener before restarting the bot.

The bot returns unauthorized for a valid-looking token.

Likely causeThe stored SHA-256 digest was generated from different bytes, includes a newline, or the caller uses the wrong authorization scheme.

Safe checks
  • Hash the presented token locally without printing it.
  • Confirm the header begins with exactly Bearer and one space.

ResolutionGenerate a new random token, store only its lowercase digest, restart the bot, and revoke the old value.

The configured model is not found.

Likely causeThe model was never pulled locally, its tag differs, or an upgrade changed the configured alias.

Safe checks
  • Run ollama list and compare exact names.
  • Query the OpenAI-compatible models endpoint locally.

ResolutionPull an approved model by exact tag, record the digest where available, and update the environment only after a harmless test.

Tool calls are never requested.

Likely causeThe chosen model lacks reliable tool calling, tools were disabled for the request, or the prompt does not require host evidence.

Safe checks
  • Confirm allowReadOnlyTools is true for a synthetic test.
  • Inspect the model capability documentation and a content-free response shape.

ResolutionChoose a tool-capable model or keep tools disabled and present the probes as explicit operator actions.

A tool name is rejected by validation.

Likely causeThe model proposed an invented function or included arguments not allowed by the strict schema.

Safe checks
  • Record only the rejected function name and request ID.
  • Run the allowlist unit test.

ResolutionTreat rejection as safe behavior; improve the system prompt or model but never widen the allowlist from model output.

A read-only probe times out.

Likely causeThe host command hung, system pressure is severe, or the process could not be killed promptly.

Safe checks
  • Run the exact documented executable and fixed argv manually as the service user.
  • Inspect content-free timeout counters and journal metadata.

ResolutionDiagnose the host separately, retain the ten-second limit, and do not replace spawn with a shell.

Tool output exceeds 64 KiB.

Likely causeThe host has an unexpectedly large mount or socket inventory, or command behavior differs from the tested version.

Safe checks
  • Run the fixed probe through head only for diagnosis.
  • Count output bytes without storing full content.

ResolutionReturn a clear truncation error, add a more selective fixed probe in code review, and preserve the output ceiling.

The bot exposes /metrics to other hosts.

Likely causeThe service bound to a wildcard address or a reverse proxy published the loopback endpoint.

Safe checks
  • Inspect ss -lntp and proxy listeners.
  • Test access from a separate host without credentials.

ResolutionRestore BOT_HOST literal loopback, restrict the proxy, and treat previous metrics exposure as an incident if labels ever contained sensitive data.

Audit writes fail with permission denied.

Likely causeThe state directory ownership, ReadWritePaths, or UMask conflicts with the service account.

Safe checks
  • Inspect directory metadata and systemd sandbox errors.
  • Test a zero-content append as ollama-bot.

ResolutionCorrect only /var/lib/ollama-operator ownership and keep ProtectSystem strict rather than broadening filesystem access.

Responses become slow after an upgrade.

Likely causeThe model tag, context, GPU runtime, or Ollama build changed, or host contention increased.

Safe checks
  • Compare ollama version, model list, and content-free latency percentiles.
  • Inspect Ollama journal GPU selection without prompts.

ResolutionRoll back the recorded package or model tag, restore the prior environment, and rerun the synthetic acceptance set.

The API returns a draft but no second model answer after tool results.

Likely causeThis reference service intentionally returns bounded evidence separately instead of running an autonomous multi-turn tool loop.

Safe checks
  • Inspect toolResults and verify exit codes.
  • Confirm the notice states no write action executed.

ResolutionHave the user review evidence and submit a new message, or implement a separately reviewed bounded second turn with strict call-count limits.

Backup restores audit data but the service will not start.

Likely causeThe environment secret, compiled code, model, service user, or directory permissions were not part of the state-only backup.

Safe checks
  • Validate the audit NDJSON independently.
  • Compare package checksum and model tag with the recovery manifest.

ResolutionRestore code and configuration from release artifacts, recreate the token separately, pull the pinned model, then attach the recovered audit directory.

After the procedure

Alternatives and next steps

Consider these alternatives

  • Expose the three diagnostics as explicit operator buttons and keep the model completely tool-free when user intent should never select probes.
  • Read host evidence from an existing monitoring system with scoped credentials rather than starting local utilities.
  • Use a managed OIDC reverse proxy for team access while keeping the bot and Ollama loopback-only.
  • Run one bot per security domain instead of sharing a model service across tenants with different data classifications.
  • Use LM Studio or another local runtime only after separately validating its compatibility, lifecycle, and operations; an OpenAI-shaped API alone is insufficient.

Operate it safely

  • Add per-client identities at a private gateway and propagate only a stable pseudonymous subject into content-free audit.
  • Add histograms for local model latency and tool runtime without prompt labels, then define alert thresholds from measured baselines.
  • Create a signed probe manifest so release review can compare executable paths and argv across versions before activation.
  • Build an optional two-turn answer synthesis only with a maximum one tool round, strict output schema, total time budget, and preserved raw evidence for the human.
  • Reassess Ollama compatibility, tool-calling behavior, chosen model, dependencies, sandbox, and restore procedure every ninety days.

Reference

Frequently asked questions

Is the Ollama api_key authenticating the local server?

No. Ollama's OpenAI compatibility documentation shows an api_key value required by the client shape but ignored by Ollama. This service authenticates at its own boundary and keeps Ollama inaccessible remotely. A team deployment should add individual identity at a TLS gateway.

Why not let the model write a shell command?

A shell command is an executable program assembled from untrusted probabilistic output. Quoting errors, prompt injection, metacharacters, path changes, and invented flags can turn a harmless intention into broad access. The compiled mapping makes the model choose only among code-reviewed operations.

Are uptime, df, and ss always harmless?

They are non-mutating in this configuration, but their output can disclose host details and they still consume resources. Authorization, output limits, retention rules, and contextual human judgment remain necessary. Read-only means no intended state change, not no security impact.

Can we add a restart_service tool?

Not to this executor. Restarting is a consequential write that can cause downtime. Design a separate proposal-and-approval workflow with exact service allowlists, prechecks, authenticated approver identity, idempotency, timeout, post-verification, rollback, and audit before implementing it.

Why return tool results separately?

It preserves evidence exactly as collected and avoids an unbounded autonomous model-tool-model loop. The operator can review the exit code and bounded output before asking for interpretation. A later synthesis turn can be added with an explicit one-round limit and tests.

Can this be exposed on the internet?

Not as built. The loopback service lacks public TLS, per-user identity, robust authorization, distributed rate limiting, abuse response, and multi-tenant data controls. Keep it private or add a separately reviewed gateway and threat model without publishing Ollama directly.

What does the backup omit?

It omits prompts, responses, raw tool output, bearer token, and normally model weights. It preserves approved audit metadata and release evidence. Recovery installs the recorded application, pulls or restores the approved model separately, and creates a new client token.

How often should this guide be reviewed?

Review every ninety days and after changes to Ollama's OpenAI compatibility, tool-calling behavior, model tag, Node or OpenAI SDK, service sandbox, tool allowlist, authentication, gateway, audit schema, or backup process.

Recovery

Rollback

Rollback restores the previous immutable application release, unit, environment structure, and model tag while preserving a copy of new audit evidence. It always rotates the client token when exposure is possible and never deletes shared Ollama model storage as part of application rollback.

  1. Stop the bot and preserve the failed release logs, content-free audit segment, checksums, and environment key names without copying secret values into tickets.
  2. Point ExecStart or the release symlink back to the previous checksum-verified dist directory and restore the reviewed unit if it changed.
  3. Restore the previous exact model tag or pinned model artifact and confirm it exists through the local model endpoint.
  4. Generate a new random bearer token, replace only its digest in the root-owned environment, and distribute the token through the approved secret channel.
  5. Reload systemd, start the bot, then run unauthenticated rejection, authenticated no-tool chat, fixed read-only tool, metrics, and audit acceptance.
  6. Keep the failed release quarantined until root cause and retention decisions are complete; remove only exact dedicated paths after approval.

Evidence

Sources and review

Verified 2026-07-29Review due 2026-10-27
Ollama Linux installation and systemd serviceofficialOllama OpenAI compatibilityofficialOllama tool callingofficialOllama structured outputsofficialOllama API introductionofficialOllama troubleshootingofficialsystemd service sandboxingofficial