Files
goclaw/docs/14-skills-runtime.md
thotam ce9e7af3fb fix(tools): price out-of-band media by measured duration, not by its bytes (#1564)
read_video called with a url parameter always failed under an agent budget:

    tool:read_video: cannot verify streamed native media against the agent
    context budget (no in-memory payload to count); refusing to send

read_audio and read_document appeared to work but only for very small files.
Both symptoms share one root cause.

5ca433b8 introduced the complete-input invariant and, to keep out-of-band
media honest, appended the standard-base64 encoding of the payload as a
synthetic guard-only message. That message is counted as text. Measured against
the bundled BudgetCounter, base64 costs 0.956 tokens per raw byte, so an 11.6 MB
video counted 11,161,058 tokens and exceeded a 200k, a 1M and a 2M window alike.
The practical ceiling was roughly 200 KB at a 200k window while videoMaxBytes is
100 MB. The read_video URL transport streams to the File API without buffering,
so it had no bytes to count at all and was routed to a helper that refused to
send whenever an agent budget was present.

Media is now priced by what a provider actually bills for it. Every number below
is either a published per-unit rate or a published provider limit; none is
derived from a byte size, because a static-image video compresses arbitrarily
small and no bitrate floor exists. An earlier revision of this branch tried one
and a 120-second, 20,627-byte clip priced at 526 tokens against a true 31,560.

  internal/mediabudget prices one payload:
  - Video and audio: ffprobe measures duration. 263 tokens/second for video,
    32 for audio, both published for static processing at 1 FPS.
  - PDF: pdfinfo counts pages at 258 tokens/page. When the page count cannot be
    read, the charge is the proven ceiling of 1000 pages, which is the most a
    provider will accept and therefore the most it can bill.
  - Video and audio that cannot be measured are refused. Upstream already
    refused unverifiable native media for every URL on the Gemini streamed path;
    this narrows that refusal from every URL to only what genuinely cannot be
    measured, rather than removing it. PDF differs because its page count is
    cheaply measurable and its ceiling is small enough to stay usable.

A remote video is measured without downloading it: two ranged GETs, 512 KB from
the head and 512 KB from the tail, written into a sparse temp file sized to the
declared total and handed to ffprobe. The tail matters because every container
that puts its index at the end keeps it there: head-only probing under-reports
mpeg by 98% and ogg by 79%, and a plain prefix makes ffprobe under-report a
30-second WAV as 0.74 seconds because it clamps to the bytes it can see. Sizing
the temp file to the real total fixes that. Verified end to end on an 11,673,105
byte MP4 served by nginx: 1 MB of ranged reads yielded duration 40.000000,
identical to ffprobe reading the whole URL, for a charge of 10,520 tokens. Those
requests reuse the existing SSRF-safe path, security.WithPinnedIP plus
security.NewSafeClient(0), and no URL is ever handed to an external binary.

Beyond the reported bug, two pre-existing gaps let large media reach a provider
almost unpriced. ExecuteWithChain treated every callProvider error as a provider
failure and advanced to the next entry, and the non-Gemini branches of read_video
and read_document reserved without pricing their payload at all. Measured under a
20,000-token window before this change, a 40 MB video and a 1000-page PDF each
reached a provider charged about 1,600 tokens. Budget refusals are now terminal
in the chain and every media branch prices its payload, so both reach no provider
at all. Genuine provider failures still fail over.

Known limits, stated rather than discovered:
- The /Type /Page scan that guards against a forged /Count is a floor, not a
  bound. Pages inside a compressed object stream are invisible to it, and a
  9,484-byte PDF built that way is charged 258 tokens for 1000 pages. A real
  pdfinfo reads such files correctly; the scan only ever raises a probed count.
- A video URL whose origin does not serve byte ranges is now refused on the
  non-Gemini path too, and the refusal is terminal. Upstream forwarded such URLs
  unpriced. A HEAD giving only a size is not enough to price one.
- read_video and read_audio require ffprobe. Docker images install it by default
  except the base variant; bare binaries and the desktop build do not ship it.
- A hostile origin can craft a container ffprobe reads as about one second.
  Reservation.Reconcile overwrites the estimate with the provider's reported
  usage, so this weakens the gate rather than defeating it.

Byte ceilings videoMaxBytes, audioMaxBytes and documentMaxBytes are unchanged.
No new module dependency; ffprobe and pdfinfo are optional runtime probes.
2026-09-12 10:31:12 +07:00

16 KiB

14 - Skills Runtime Environment

How skills access Python, Node.js, and system tools inside Docker containers and bare-metal gateway deployments. Covers image variants, pre-installed packages, runtime installation, and security constraints.


1. Architecture Overview

┌─────────────────────────────────────────────────────────┐
│  Docker Container (Alpine 3.22, read_only: true)        │
│                                                         │
│  ┌─────────────────┐  ┌──────────────────────────────┐  │
│  │  Pre-installed   │  │  Writable Runtime Dir        │  │
│  │  (image layer)   │  │  /app/data/.runtime/         │  │
│  │                  │  │                              │  │
│  │  latest/alpine   │  │  pip/        ← PIP_TARGET   │  │
│  │  no py/node      │  │  pip-cache/  ← PIP_CACHE    │  │
│  │  python/node/full│  │  npm-global/ ← NPM_PREFIX   │  │
│  │  add runtimes    │  │                              │  │
│  └─────────────────┘  └──────────────────────────────┘  │
│                                                         │
│  Volumes (read-write):                                  │
│    /app/data      ← goclaw-data volume                  │
│    /app/workspace ← goclaw-workspace volume             │
│                                                         │
│  tmpfs (noexec):                                        │
│    /tmp           ← 256MB, no executables               │
└─────────────────────────────────────────────────────────┘

Explicit skill activation is handled before runtime execution. When a user starts their prompt with /<skill-slug> or /use <skill name>, the gateway resolves the skill, injects its SKILL.md into the current turn, and then normal runtime rules apply to any scripts or package dependencies that skill uses.


2. Pre-installed Packages (Option A)

Pre-installed runtimes depend on the Docker image variant you deploy. The Packages page and /v1/packages/runtimes report what exists inside the active GoClaw container, not what exists on the host machine.

Runtime Variant Matrix

Variant Published tag Build args Pre-installed runtimes
Latest latest ENABLE_PYTHON=true, ENABLE_NODE=false, ENABLE_FULL_SKILLS=false, ENABLE_MEDIA_PROBES=true python3, py3-pip, shared Python deps, ffprobe, pdfinfo
Base base ENABLE_PYTHON=false, ENABLE_NODE=false, ENABLE_FULL_SKILLS=false, ENABLE_MEDIA_PROBES=false No Python, Node.js, ffprobe, or pdfinfo
Full full ENABLE_FULL_SKILLS=true, ENABLE_MEDIA_PROBES=true python3, py3-pip, nodejs, npm, pandoc, github-cli, poppler-utils, bundled skill deps, Workspace CLI, ffprobe, pdfinfo
Custom Python not published ENABLE_PYTHON=true python3, py3-pip, shared Python deps
Custom Node not published ENABLE_NODE=true nodejs, npm

ENABLE_MEDIA_PROBES (default true) installs ffmpeg for its ffprobe binary and poppler-utils for its pdfinfo binary. The media budget guard charges a measured duration or page count, never a number derived from a payload's byte size. Without these binaries, which is the case for the base variant, the desktop build, and bare-binary deployments:

  • read_video and read_audio refuse the call and name the missing ffprobe in the error.
  • read_document charges every PDF the provider's 1000-page ceiling (258,000 tokens), so it still works wherever the agent's context window can hold that.

Full Variant Extras

Python Packages

Package Version Used By
pypdf latest pdf skill
openpyxl latest xlsx skill
pandas latest xlsx skill (data analysis)
python-pptx latest pptx skill
markitdown latest pptx skill (content extraction)

Node.js Packages (global)

Package Used By
docx docx skill (document creation)
pptxgenjs pptx skill (presentation creation)
@googleworkspace/cli (gws) Google Workspace CLI for Drive, Gmail, Calendar, and Workspace APIs

3. Runtime Package Installation (Option B)

The entrypoint (docker-entrypoint.sh) configures writable directories so agents can install additional packages at runtime without sudo.

Environment Variables (set by entrypoint)

# Python
PYTHONPATH=/app/data/.runtime/pip
PIP_TARGET=/app/data/.runtime/pip
PIP_BREAK_SYSTEM_PACKAGES=1
PIP_CACHE_DIR=/app/data/.runtime/pip-cache

# Node.js
NPM_CONFIG_PREFIX=/app/data/.runtime/npm-global
NODE_PATH=/usr/local/lib/node_modules:/app/data/.runtime/npm-global/lib/node_modules
PATH=/app/data/.runtime/npm-global/bin:/app/data/.runtime/pip/bin:$PATH

How It Works

  1. Python: pip3 install <package> installs to /app/data/.runtime/pip/ (writable volume). PYTHONPATH ensures Python finds packages there.
  2. Node.js: npm install -g <package> installs to /app/data/.runtime/npm-global/. NODE_PATH includes both system globals (/usr/local/lib/node_modules) and runtime globals.
  3. Persistence: Packages installed at runtime persist across tool calls within the same container lifecycle (volume-backed).

Bare-Metal Ubuntu/Debian

When the gateway runs directly on Ubuntu/Debian instead of inside the Alpine Docker image:

  1. pip:<name> still runs pip3 install --break-system-packages <name>.
  2. npm:<name> runs npm install -g <name> with a GoClaw-owned prefix at {runtimeDir}/npm-global instead of /usr/lib/node_modules.
  3. Bare system package names use sudo -n apt-get install -y --no-install-recommends <name>.
  4. Compatibility aliases: pip3 installs python3-pip; github-cli installs gh.
  5. Installed apt packages are recorded in {runtimeDir}/system-packages.json so the System Packages table can show the user-facing name (github-cli) while checking the real apt package (gh).
  6. /tmp/pkg.sock is Docker/Alpine-only and is not required on bare-metal Ubuntu/Debian.

Default {runtimeDir} resolution:

  1. RUNTIME_DIR, when set.
  2. GOCLAW_DATA_DIR/.runtime, when GOCLAW_DATA_DIR is set.
  3. /var/lib/goclaw/data/.runtime on bare-metal Linux.
  4. /app/data/.runtime in Docker-style runtime.

Agent Guidance

The system prompt and UI should treat runtime availability as variant-dependent:

Published `latest`: Python is present; Node may be missing in the container.
Published `full`: Python, Node, and full skill extras are present.
Published `base`: Python and Node are absent.
Custom builds can set ENABLE_PYTHON=true or ENABLE_NODE=true.
To install additional packages: pip3 install <pkg> or npm install -g <pkg>

4. Security Constraints

Constraint Detail
read_only: true Container rootfs is immutable; only volumes are writable
/tmp is noexec Cannot execute binaries from tmpfs
cap_drop: ALL No privilege escalation
no-new-privileges Prevents setuid/setgid
Exec deny patterns Blocks curl | sh, reverse shells, crypto miners, etc. (see shell.go)
.goclaw/ denied Exec tool blocks access to .goclaw/ except .goclaw/skills-store/

What Agents CAN Do

  • Run Python/Node scripts via exec tool
  • Install packages via pip3 install / npm install -g
  • Access files in /app/workspace/, including .uploads/ for current user uploads and .media/ for legacy media refs
  • Read skill files from .goclaw/skills-store/

What Agents CANNOT Do

  • Write to system paths (rootfs is read-only)
  • Execute binaries from /tmp (noexec)
  • Access .goclaw/ except skills-store
  • Run denied shell patterns (network tools, reverse shells, etc.)

5. Media File Access

Uploaded files (from web chat, Telegram, Discord, etc.) are persisted to:

/app/workspace/.uploads/{safe-original-name}-{8hex}.{ext}

Uploads without a usable original filename fall back to {uuid}.{ext}. Legacy media refs may still resolve from .media/{sessionHash}/{uuid}.{ext}.

The enrichDocumentPaths() function injects the exact media ID and a logical path relative to the active agent workspace into <media:document> tags:

<media:document name="report.pdf" id="..." path=".uploads/report-a1b2c3d4.pdf">

Normal agent runs can read these workspace files directly via exec — no copy to /tmp is needed. Agent Link delegations instead receive selected files as read-only inputs/... paths in an isolated delegation exchange. Their exec calls fail closed unless an active sandbox is available, and generated files must be written under outputs/ for validation and publication back to the caller. For archive uploads such as .zip, inspect or extract them within the authorized workspace or delegation paths.


6. Bundled Skills

Skills shipped with the Docker image at /app/bundled-skills/. Lowest priority in the loader hierarchy — user-uploaded skills (managed/skills-store) override them.

Bundled Skills List

Skill Purpose
pdf Read, create, merge, split PDFs
xlsx Read, create, edit spreadsheets
docx Read, create, edit Word documents
pptx Read, create, edit presentations
skill-creator Create new skills
workspace-organizing Organize shared workspaces and generated files
goclaw Operate and debug GoClaw gateway CLI/runtime administration

How It Works

  1. Skills source files live in skills/ directory in the repo
  2. Dockerfile copies them to /app/bundled-skills/ in the image
  3. gateway.go passes this path as builtinSkills to skills.NewLoader()
  4. Loader priority: workspace > project-agents > personal-agents > global > managed > builtin

When a user uploads a skill with the same name via the UI, the managed version takes precedence.

Adding a New Bundled Skill

  1. Place skill directory under skills/<name>/ with SKILL.md at root
  2. Rebuild: docker compose ... up -d --build

7. Adding New Pre-installed Packages

To add a new package to the Docker image:

  1. Python: Add to the pip3 install line in Dockerfile (usually full, sometimes python)
  2. Node.js: Add to the npm install -g line in Dockerfile (usually full, sometimes a custom ENABLE_NODE=true build)
  3. System tool: Add to the apk add line in Dockerfile
  4. Docs/UI guidance: Update runtime variant docs and any UI copy that describes pre-installed tools
  5. Rebuild: docker compose ... up -d --build

For packages only needed by specific skills, prefer runtime installation (Option B) to keep the image lean.

GitHub Releases Installer

For CLI tools distributed as GitHub Releases (lazygit, starship, ripgrep, gh, etc.) that aren't packaged via apk/pip/npm, use the github: runtime installer:

github:owner/repo[@tag]

Admin-only, SHA256-verified, ELF-validated, with a release-picker UI. Binaries land in {runtimeDir}/bin/ (on $PATH). See docs/packages-github.md for syntax, configuration, security posture, and troubleshooting (especially musl/glibc compatibility).

Update Flow (Phase 1: GitHub only)

GitHub binaries support proactive update checking via:

  • UI summary bar on the Runtime & Packages page (badge + Refresh + Update All)
  • /v1/packages/updates* endpoints (master-scope for writes)
  • Atomic two-phase .bak swap with automatic rollback
  • ETag-aware polling (304 = zero rate-limit cost)
  • Pre-release handling via regex + release.prerelease + semver ordering

See docs/packages-github.md § "Updating Installed Packages" for the full contract, troubleshooting, and runbook.

Pip/npm/apk update flows are deferred to Phase 2 — the UpdateChecker / UpdateExecutor interfaces in internal/skills/update_registry.go are designed for interface-based extension without Phase 1 refactor.


8. Skill Search (v3)

Skills are searchable via BM25 keyword + semantic similarity matching (in internal/skills/search.go). The skill loader indexes all available skills from workspace/project/global/builtin sources. Skill discovery combines keyword matching with embeddings for improved recall of relevant tools to agent tasks.


9. Declaring Dependencies in SKILL.md

Auto-scan (internal/skills/dep_scanner.go) parses Python imports and npm requires from scripts/ — adequate for most cases but has two limitations:

  1. Import name ≠ pip package name for many packages (e.g. import psycopg2 → must pip install psycopg2-binary because the sdist-only psycopg2 package requires pg_config at build time). An import-to-pip alias table in dep_checker.go handles common cases (psycopg2→psycopg2-binary, psycopg→psycopg[binary], MySQLdb→mysqlclient, Crypto→pycryptodome, serial→pyserial, skimage→scikit-image, Levenshtein→python-Levenshtein, plus the existing cv2/PIL/yaml/sklearn/bs4/dateutil/dotenv/pptx/docx/attr/gi set).
  2. False positives — local helper modules detected as external deps.

Skill authors can override auto-scan with two optional frontmatter fields:

---
name: my-skill
description: does things
deps:            # authoritative: when present, supersedes auto-scan for install
  - pip:psycopg2-binary
  - pip:requests>=2.31
  - pip:psycopg[binary]
  - npm:typescript
  - system:ffmpeg
  - github:cli/cli@v2.40.0
exclude_deps:    # filter false positives from auto-scan; ignored when deps: is set
  - pip:my_local_helper
---

Prefix semantics:

Prefix Effect Example
pip: Python pip install pip:psycopg2-binary, pip:requests>=2.31
npm: Global npm install under GoClaw runtime prefix npm:typescript, npm:@aiagentwiki/cli
github: GitHub Releases installer (admin) github:cli/cli@v2.40.0
system: apk package via pkg-helper system:ffmpeg
(bare) Treated as system binary pandoc

Precedence:

deps: exclude_deps: Behavior
absent absent Auto-scan as today
absent present Auto-scan minus exclude_deps entries
present — Explicit deps used (authoritative); auto-scan kept only for advisory log

v1 limitations:

  • Version pins in pip:requests>=2.31 are stripped when checking whether the import is available (checker imports requests); the installer currently installs latest. Full pin pass-through is planned for v2.
  • deps: bypasses the import-to-pip alias map, so authors must declare the exact pip package name (e.g. pip:psycopg2-binary, not pip:psycopg2).
  • Unknown prefixes in deps: are treated as system binaries.
  • exclude_deps matches surface in slog.Debug only; no UI diagnostic yet.

Validation & safety:

Manifest dep strings are passed to python3 -c / node -e at check time, so each entry is validated against a per-category allowlist before use:

Category Allowed chars Example reject
pip: [A-Za-z_][A-Za-z0-9_.-]* pip:foo;__import__('os')...
npm: ^(@scope/)?[a-z0-9][a-z0-9_.-]* npm:a');require(...
system: / bare [A-Za-z0-9][A-Za-z0-9._+-]* rm -rf /, $(evil)

Invalid entries are dropped with slog.Warn("skills: dropping invalid manifest dep", ...). Malformed specs like pip:>=1.0 (no package name) or pip:[binary] (extras only) are also dropped.

YAML grammar subset accepted by the loader:

  • Flat list only: deps:\n - item1\n - item2
  • Quoted items OK ("..." or '...')
  • CRLF normalized
  • Flow-style [a, b] NOT supported (returns empty)
  • Dash without space -item NOT supported
  • Nested maps dropped with warning (avoids silent prefix-loss miscategorization)