Skip to content
The archive is in testingEverything you make here, accounts and runs included, will be wiped before launch on 1 October 2026.

The agent's tools and settings

claude-sonnet-5 on Claude CodeRead the log

Tools

ToolDescriptionParameters
portal_execRun async JavaScript against Portal through SPT; `portal` is in scope. Use `return <value>` for text results. Screenshots are returned as images. TAS: `const t = portal.tas()`, queue inputs, then `await t.run(options)` (~67 ticks/s). `t.hold(ticks, keys, angles?)` holds the exact key set; keys are forward, back, left, right, jump, duck, use, attack, attack2 (aliases: crouch, blue, orange). Helpers: wait, tap, jump, use, fire, and look. Angles use relative up/down/left/right or absolute pitchTo/yawTo. A run returns `{ ticks, aborted?, reason?, facing?, position? }` plus a 360p screenshot. Run options include `{ screenshot: false, position: false, fullRes: true }`. While paused: `portal.look.left/right/up/down(degrees)`, `portal.facing()`, `portal.position()`, `portal.observe(['facing', 'position'])`, and `portal.screenshot()`. Also available: `portal.run(steps)`, `portal.seconds(s)`, and `portal.abort()`.
code
string JavaScript executed as an async function body with `portal` in scope.
portal_documentationReturn the complete supported JavaScript API reference for the `portal` object used inside portal_exec. Call this when exact methods, arguments, option fields, or result types are needed.none
portal_screenshotCapture a full-resolution screenshot of Portal. By default it is returned as an image. Pass `savePath` to save the image to that file instead, without returning the image to the agent.
savePath (optional)
string Optional file path where the image should be saved instead of returned; relative paths use the broker's working directory, missing parent directories are created, and existing files are overwritten.

Instructions

AGENTS.md

Portal agent

You are driving Portal inside a workspace with web access disabled. The tools for game interaction are provided:

  • portal_documentation: Return the complete supported portal JavaScript API reference.
  • portal_exec: Run JS against the live controller. Any await portal.screenshot() or TAS run result is returned as an image automatically.
  • portal_screenshot: Capture a full-resolution screenshot.

The core loop

  1. Aim with portal.look while the game is paused (the paused view updates on screen, so screenshot again to verify).
  2. Build a plan with portal.tas().
  3. await t.run() - SPT plays your plan, pauses again when done, and returns a screenshot plus the final view direction & player position by default.
  4. Inspect the result and plan the next move. Time advances during playback, not while you are building the plan.

Example plan:

const t = portal.tas();
t.hold(67, { forward: true });          // walk forward for 1 second
t.hold(33, { forward: true }, { left: 45 }); // keep walking, turn 45 degrees left
t.fire("blue");                          // fire the blue portal (press + ~0.5 s settle)
const r = await t.run();                 // play it back; screenshot comes back
return r.facing;

Ticks are game time: ~67 ticks = 1 second (portal.seconds(s) converts).

Notes

  • The view turns instantly at step boundaries. For smooth camera motion (e.g. while carrying an object with use), split a big turn across several short steps: for (let i = 0; i < 10; i++) t.hold(3, { forward: true }, { left: 3 });
  • If you're holding an object (tapped use near it), avoid collisions or the object might drop. Tap use again to set it down.
  • There's a 0.5-second (~33 ticks) cooldown for Portal gun firing.

What the documentation tool returned

documentation.md

The whole text

Portal JavaScript API Reference

This is the supported portal JavaScript API surface available inside portal_exec. The portal object is already constructed and connected by the MCP server.

interface PortalController {
  tas(): TasBuilder;                       // Start building a TAS plan.
  run(steps: NonEmptyTasSteps, options?: RunOptions): Promise<TasRunResult>; // Play 1..1000 raw steps.
  look: PortalLookApi;                     // Relative view turns while paused.
  facing(options?: RequestOptions): Promise<Facing>; // Read current view without changing it.
  position(options?: RequestOptions): Promise<Position>; // Read world-space player origin.
  observe(fields: NonEmptyObservationFields, options?: RequestOptions): Promise<Observation>;
  seconds(value: number): number;          // Positive finite seconds -> nearest tick (~66.7/s, minimum 1).
  abort(): Promise<void>;                  // Stop an active tas_run; rejects if none is active.
  screenshot(options?: ScreenshotOptions): Promise<ScreenshotResult>;
}

interface TasBuilder {
  steps: WireTasStep[];                    // Normalized steps queued so far (SPT wire shape).
  totalTicks: number;
  hold(ticks: number, keys?: TasKeys, angles?: TasAngles): this;
  wait(ticks: number): this;
  tap(key: TasKeyName, ticks?: number): this;   // default 3 ticks
  jump(ticks?: number): this;                   // default 3 ticks
  use(ticks?: number): this;                    // 3-tick press + settle wait; `ticks` is the TOTAL (default 33, ~0.5 s)
  fire(color: "blue" | "orange", ticks?: number): this; // same press+settle shape as use()
  look(angles: TasAngles, ticks?: number): this; // default 1 tick
  run(options?: RunOptions): Promise<TasRunResult>; // Consumes the queued steps.
}

type TasKeyName =
  | "forward" | "back" | "left" | "right"
  | "jump" | "duck" | "use" | "attack" | "attack2"
  | "crouch" | "blue" | "orange";          // aliases for duck/attack/attack2

type TasKeys = Partial<Record<TasKeyName, boolean>>;

type TasAngles = {
  up?: number; down?: number; left?: number; right?: number; // relative degrees (non-negative)
  pitch?: number; yaw?: number;            // raw Source deltas (pitch > 0 down, yaw > 0 left)
  pitchTo?: number; yawTo?: number;        // absolute Source angles
  pitch_to?: number; yaw_to?: number;      // accepted SPT wire aliases for pitchTo/yawTo
};

type TasStep = {
  ticks: number;                           // integer 1..6600
  keys?: TasKeys;
} & TasAngles;

type WireTasStep = {
  ticks: number;
  keys?: TasKeys;
  pitch?: number; yaw?: number;
  pitch_to?: number; yaw_to?: number;
};

type NonEmptyTasSteps = [TasStep, ...TasStep[]]; // 1..1000 steps; plan total <= 6600 ticks

type RunOptions = Omit<ScreenshotOptions, "timeoutMs"> & {
  screenshot?: boolean;                    // default true: screenshot after playback
  position?: boolean;                      // default true: request final player origin
  timeoutMs?: number;                      // TAS completion timeout; default 2x nominal playback + 15 s
                                             // Also used for the automatic screenshot request.
};

type Facing = { pitch: number; yaw: number; roll: number };
type Position = { x: number; y: number; z: number }; // world-space player origin
type ObservationField = "facing" | "position";
type NonEmptyObservationFields = [ObservationField, ...ObservationField[]];
type Observation = {
  facing?: Facing;
  position?: Position;
  unavailable?: Partial<Record<ObservationField, string>>;
};

// Failures reject with an Error; fields that carry no information are omitted.
type TasRunResult = {
  ticks: number;                           // simulated ticks
  aborted?: true;                          // present only when playback was aborted
  reason?: string;                         // abort reason, present only when aborted
  facing?: Facing;                         // view after playback (Source angles), when reported
  position?: Position;                     // present by default when available
  unavailable?: Partial<Record<ObservationField, string>>;
  screenshots?: Array<PortalScreenshot>;   // present unless { screenshot: false }
};

interface PortalLookApi {
  left(angle: number): Promise<LookResult>;  // angle must be finite and non-negative
  right(angle: number): Promise<LookResult>; // angle must be finite and non-negative
  up(angle: number): Promise<LookResult>;    // angle must be finite and non-negative
  down(angle: number): Promise<LookResult>;  // angle must be finite and non-negative
}

type LookResult = {
  facing?: Facing;                         // view after the turn, when reported
};

type RequestOptions = {
  timeoutMs?: number;                      // request timeout override; default 5 s
};

type ScreenshotOptions = RequestOptions & {
  fullRes?: boolean;                       // default false: reduce images taller than 360 px to 360 px
  quality?: number;                        // JPEG quality; default 85, rounded and clamped to 1..100
};

type ScreenshotResult = {
  screenshots: Array<PortalScreenshot>;
};

type PortalScreenshot = {
  url: string;
  width?: number;
  height?: number;
};

Direct screenshot tool

The standalone MCP tool portal_screenshot captures at full resolution and returns an image block by default. Pass { savePath: "path/to/screenshot.jpg" } to save the JPEG to that file instead. In save mode, the tool returns only a text confirmation and does not show the image to the agent. Missing parent directories are created automatically, and an existing file is overwritten.

Execution semantics

A TasBuilder queues its plan locally; no game input is sent until run() is called. Calling run() consumes the queued steps, plays them in order, and pauses the game again after playback.

Every hold() or raw TasStep describes the complete button state for that step. Keys omitted from a step are released. To keep holding a key across consecutive steps, include it in every step.

Relative or absolute angle changes are applied immediately on the step's first tick. Relative and absolute angles cannot be mixed for the same axis in one step.

Each TAS step must use an integer tick count from 1 through 6600. A run must contain 1 through 1000 steps, and their combined duration must not exceed 6600 ticks.

State across portal_exec calls

Each portal_exec snippet runs as the body of a new async function. Local const, let, and var declarations therefore last only for that tool call:

const position = await portal.position();
return position;

The MCP server itself is a persistent Node.js process. To retain state across calls, store it on globalThis, preferably under a single namespaced property:

globalThis.portalState ??= {
  attempts: 0,
  positions: [],
};

globalThis.portalState.attempts += 1;
globalThis.portalState.positions.push(await portal.position());
return globalThis.portalState;

A later call can read or update the same value:

return globalThis.portalState?.positions.at(-1);

Values stored this way remain live JavaScript values rather than serialized copies, so objects, functions, and class instances can persist. State lasts only for the lifetime of the MCP server process and is lost when that process exits or restarts. Avoid overwriting server-owned globals, especially globalThis.portal and globalThis.nodeRepl.

Runtime and game settings

agent_run.cfg

game-config
// User-owned Portal agent run lifecycle.
// Demos are written to agent_runs/<timestamp>/ and continue across map
// transitions, save loads, deaths, and retries until stop_run is called.

alias start_run "spt_agent_start_run"
alias stop_run "spt_agent_stop_run"

echo "Agent run commands loaded: start_run / stop_run"

autoexec.cfg

game-config
sv_cheats 1
y_spt_ipc_port 27182
y_spt_ipc 1
engine_no_focus_sleep 0
y_spt_cvar sv_accelerate 100
y_spt_cvar sv_friction 100
y_spt_cvar sv_stopspeed 200
y_spt_ipc_expose_position 1
y_spt_cvar fps_max 66.666667
exec agent_run

spt.vdf

game-config
Plugin
{
	file		"spt.dll"
}

LICENSE

game-config
MIT License

Copyright (c) 2026 cozyblaze

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

UPSTREAM.json

game-config
repository
https://github.com/OutOfBoundsOffice/SourcePauseTool.git
base_commit
3dfdd68314a9a47e2dc969706234814eacbf9cc7
local_snapshot_commit
e21b40d696b7754919353ffcf5d45803bb853371
includes_uncommitted_changes
yes
snapshot_date
2026-09-06
snapshot_time_zone
America/Los_Angeles
sptlib_commit
621c0516af17c12a80104e6549d509a233103d55
run_version_confirmed_by
cozyblaze
note
The published source is the version used during the run. The publication snapshot was collected on September 6, 2026 (San Francisco time, PDT / UTC-7) and includes the roll observation-only change, which was present in the local working tree but not yet committed there.

mcp.template.json

runtime-config
mcpServers
portal
3 fields
command
node
args
--permission--allow-fs-read=__REPO__/packages/core--allow-fs-read=__REPO__/games/portal--allow-fs-read=__RUN_DIR__--allow-fs-read=__REPO__/.local/portal-agent--allow-fs-write=__RUN_DIR____REPO__/packages/core/src/broker.mjs
env
4 fields
AAS_PORTAL_GAME_ROOT
__ENV__
AAS_GAME_MODULE
__REPO__/games/portal/plugin.mjs
AAS_RUN_DIR
__RUN_DIR__
AAS_ALLOWED_ENDPOINTS
127.0.0.1:27182

settings.json

runtime-config
permissions
allow
mcp__portal__portal_documentationmcp__portal__portal_screenshotmcp__portal__portal_exec
deny
BashWebFetchWebSearchAgentReadEditWriteNotebookEditGlobGrepSkillTodoWrite
defaultMode
acceptEdits
enableAllProjectMcpServers
yes
enabledMcpjsonServers
portal