Skip to main content

Command Authoring

This guide explains how to add a site command in the Otto extension runtime without breaking protocol, auth, or lifecycle guarantees. After completing these steps, your command will be discoverable via otto commands list, executable via otto cmd, and testable via otto test.

Before you start

Source of truth

ConcernPath
Command types and metadata contractsextension/src/commands/types.ts
Site command registrationextension/src/commands/index.ts
Site command orchestrationextension/src/runtime/command-runtime.ts
Action execution dispatchextension/src/runtime/command-executor.ts

Steps

1. Create a command module

Create extension/src/commands/<site>/<command-id>.ts. Declare metadata that matches actual runtime behavior — runtime uses metadata to gate execution and sanitize inputs before your handler runs.

import type { SiteCommand } from '../types.js';

export const getItemsCommand: SiteCommand = {
metadata: {
site: 'example.com',
id: 'getItems',
displayName: 'Get Items',
requiresAuth: false,
inputFields: [
{ name: 'limit', type: 'number', optional: true, description: 'Max items to return' }
]
},
async execute(ctx, input) {
const limit = Number((input as { limit?: number }).limit ?? 20);
const items = await ctx.executeScript((max: number) => {
return Array.from(document.querySelectorAll('[data-item]'))
.slice(0, max)
.map((el) => ({ text: (el.textContent ?? '').trim() }));
}, [limit]);
return { count: items.length, items };
}
};

2. Understand the metadata contract

FieldRequiredPurpose
siteYesSite bundle ownership and tab URL validation
idYesCommand identifier used in command.run / command.test
requiresAuthYesWhether auth preflight runs before execute
requiresDebuggerFocusNoOpt in to focus emulation via chrome.debugger
preloadHostNoGuarantee navigation to host before execute
inputFieldsNoDeclarative input schema; drives runtime validation
inputAtLeastOneOfNoCross-field conditional requirement

3. Implement execute with bounded, deterministic logic

Keep execute(ctx, input, authMode) bounded: no infinite loops, no unbounded DOM scraping. Return deterministic errors instead of silent retries. Never automate credential submission.

4. Handle inside-page errors explicitly

When using ctx.executeScript or ctx.executeScriptWithDomHelpers, Chromium can silently swallow in-page throws. Use this pattern to preserve page-level errors:

const result = await ctx.executeScriptWithDomHelpers(async () => {
try {
// Your page logic here
return { ok: true };
} catch (error) {
return {
__ottoSerializedCommandError: true,
code: 'site_specific_error_code',
message: error instanceof Error ? error.message : 'site_specific_error_code',
};
}
}, []);

if (ctx.isSerializedScriptError(result)) {
return result;
}

This keeps command failures deterministic and surfaces specific inside-page diagnostics (for example reddit_post_comment_composer_missing) in otto test output.

5. Add a test hook for stream commands (optional)

For commands that stream network events, add test(ctx, input, helpers). See Command Authoring Templates for a copy-ready stream test hook template.

6. Register the command in the site bundle

Add your command to extension/src/commands/index.ts in the relevant site bundle. The command is now discoverable via command.list.

7. Write tests

Add tests covering validation gating, auth preflight behavior, and execute/test fallback semantics. See the testing matrix below.

Runtime validation behavior

Runtime validates inputs strictly when inputFields is declared:

ConditionError code
Unknown input keyunexpected_command_input
Missing required fieldmissing_command_input
Type mismatchinvalid_command_input_type
Unmet cross-field constraintmissing_command_input_one_of

Validation errors reject the command before execute runs. Command handlers always receive sanitized, validated input.

Verify success

After registering your command:

# Confirm it appears in discovery
otto commands list --site example.com

# Run it with otto test
otto test example.com getItems

# Run with explicit input
otto test example.com getItems --payload '{"limit": 5}'

# Inspect target page content quickly (defaults to markdown)
otto extract-content https://example.com

A successful run returns a JSON result with messageType: result and exits with code 0.

For extraction-heavy debugging, prefer otto extract-content over hand-written primitive sequences. It consolidates output selection in one place:

  • --format markdown (default) for quick page understanding
  • --format clean_html --selector <css> for selector discovery and DOM debugging
  • --format distilled_html for readability-safe article-style capture
  • --format raw_html --selector <css> only when exact unfiltered markup is required
  • --format text --tab-session <id> for visible text extraction from managed tabs

Safety rules

  • Never automate credential submission. Use manual_login_required handoff for auth-required commands.
  • Keep site URL validation strict. Commands run only on matching tab domains.
  • Return deterministic precondition errors instead of silent retries.
  • Keep command output free of sensitive values.
  • Keep output shape stable enough for CLI and agent parsing.

Testing matrix

ScenarioWhy it matters
Successful execution with valid inputConfirms happy-path contract and payload shape
Missing required inputVerifies metadata validation gating
Unexpected input keyPrevents hidden/legacy payload drift
requiresAuth command on unauthenticated pageVerifies explicit manual_login_required handoff
command.test stream declaration pathConfirms stream lifecycle and listener manifest behavior
command.test execute fallback pathEnsures compatibility for commands without custom test hook

Next steps