The agent's tools and settings
Tools
| Tool | Description | Parameters |
|---|---|---|
| portal_exec | Run 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()`. |
|
| portal_documentation | Return 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_screenshot | Capture 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. |
|
Instructions
AGENTS.mdPortal agent
You are driving Portal inside a workspace with web access disabled. The tools for game interaction are provided:
portal_documentation: Return the complete supportedportalJavaScript API reference.portal_exec: Run JS against the live controller. Anyawait portal.screenshot()or TAS run result is returned as an image automatically.portal_screenshot: Capture a full-resolution screenshot.
The core loop
- Aim with
portal.lookwhile the game is paused (the paused view updates on screen, so screenshot again to verify). - Build a plan with
portal.tas(). await t.run()- SPT plays your plan, pauses again when done, and returns a screenshot plus the final view direction & player position by default.- 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
usenear it), avoid collisions or the object might drop. Tapuseagain to set it down. - There's a 0.5-second (~33 ticks) cooldown for Portal gun firing.
What the documentation tool returned
documentation.mdThe 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