novo agents

Environments

Customer-hosted adapter that backs an agent's filesystem and shell. Novo POSTs each tool call to your endpoint, signed; you execute it and respond.

Methods

| Method | Route | |---|---| | novo.environments.register({...}) | POST /v1/environments | | novo.environments.list() | GET /v1/environments | | novo.environments.get(id) | GET /v1/environments/:id | | novo.environments.update(id, {...}) | PATCH /v1/environments/:id | | novo.environments.delete(id) | DELETE /v1/environments/:id | | novo.environments.rotateSecret(id, { secret }) | POST /v1/environments/:id/rotate-secret |

Type signature

type EnvironmentsNamespace = {
  register(input: {
    type: 'http_workspace';
    name: string;
    url: string;
    secret: string;
    requestTimeoutMs?: number | null;
    metadata?: Metadata;
  }): Promise<Environment>;
  list(): Promise<Page<Environment>>;
  get(environmentId: string): Promise<Environment>;
  update(environmentId: string, input: {
    name?: string;
    url?: string;
    requestTimeoutMs?: number | null;
    metadata?: MetadataPatch;
  }): Promise<Environment>;
  delete(environmentId: string): Promise<{ id: string; object: 'environment'; deleted: true }>;
  rotateSecret(environmentId: string, input: { secret: string }): Promise<Environment>;
};

Register

const env = await novo.environments.register({
  type: 'http_workspace',
  name: 'staging-east',
  url: 'https://workspace.acme.dev/novo',
  secret: 'env_secret_…',
});

secret is the HMAC key Novo signs requests with. You verify with verifyNovoSignature.

The dispatch protocol

For every tool call routed through the environment, Novo sends:

POST {url} HTTP/1.1
Content-Type: application/json
Novo-Environment-Id: env_…
Novo-Workspace-Method: runCommand
Novo-Signature: t=…,v1=…
Novo-Timestamp:
Novo-Request-Id: req_…
Novo-Org-Id: org_…
Novo-Agent-Id: agent_…
Novo-Run-Id: run_…

{ "method": "runCommand", "args": {  }, "options": {  } }

Methods Novo dispatches: readFile, listFiles, searchFiles, findFiles, editFile, applyPatch, runCommand, readCommandOutput, restore, writeRuntimeArtifact, getRunArtifacts, listExternalFileChanges.

Every response must match the response shape documented for that method. Novo validates it before returning to the agent; malformed responses become model-facing tool-output-error with code environment_response_invalid.

Method specs

readFile

Read a UTF-8 file relative to the workspace root.

{ args: { path: string; start_line?: number; end_line?: number; max_bytes?: number; lines_threshold?: number } }
{
  path: string;
  content: string;
  encoding: 'utf8' | 'base64';
  bytes: number;
  truncated: boolean;
  mtimeMs: number;
  start_line?: number;
  end_line?: number;
  total_lines?: number;
  total_lines_partial?: boolean;
}

listFiles

List directory entries.

{ args: { path?: string; recursive?: boolean; include_hidden?: boolean; max_entries?: number } }
{ root: string; entries: Array<{ path: string; type: 'file' | 'directory' | 'symlink'; bytes?: number }>; truncated: boolean }

Novo resolves defaults before dispatch (path: '.', recursive: false, include_hidden: false, max_entries: 500); treat a missing field as its default anyway.

searchFiles

Content search (grep-shaped).

{ args: { pattern: string; path?: string; glob?: string; literal?: boolean; ignore_case?: boolean; context?: number; limit?: number; max_results?: number; max_bytes_per_match?: number } }
{ matches: Array<{ path: string; line: number; preview: string; before?: string[]; after?: string[] }>; truncated: boolean; files_searched: number }

path may be a directory (search every file under it) or a single file (grep just that file). Backends must support both: agents routinely grep a single file directly, for example to extract a field from a large tool result the runtime spilled to a workspace artifact.

pattern is a regular expression unless literal is set, in which case it is matched as a plain string. ignore_case is case-insensitive matching; context returns that many lines on each side of a match (as before/after); limit caps matches per file; max_results caps the total across the walk. The fields are optional on the wire, but Novo resolves defaults before dispatch, so current runtimes always send fully-specified values (path: '.', literal: false, ignore_case: false, context: 0, limit: 100, max_results: 100). Treat a missing field as its documented default anyway; max_bytes_per_match stays backend-chosen.

findFiles

Filename glob search.

{ args: { pattern: string; path?: string; max_entries?: number } }
{ pattern: string; entries: Array<{ path: string; bytes?: number; mtimeMs?: number }>; truncated: boolean }

Novo resolves defaults before dispatch (path: '.', max_entries: 200); treat a missing field as its default anyway.

editFile

Apply a string replacement.

{ args: { path: string; operation: 'create' | 'replace' | 'insert_after' | 'insert_before' | 'append'; contents?: string; find?: string; replace_all?: boolean; dry_run?: boolean } }
{ path: string; dry_run: boolean; bytes_written: number; created?: boolean; rollback_token?: string; warnings?: string[] }

Return created: true when the edit created the file; omitting it is treated as an in-place modify (created: false).

replace_all is optional and only valid with operation: 'replace' plus a find string. When true, replace every exact raw occurrence of find; do not apply fuzzy matching, indentation repair, or regex semantics. When find matches more than once and replace_all is false or omitted, return a workspace_find_not_unique error.

When a mutating operation (replace, insert_after, insert_before, append) arrives without contents, return a workspace_contents_required error rather than treating it as empty text; Novo's managed runtimes do the same, so an agent that meant to pass replacement text is coached instead of silently deleting or truncating. An explicit contents: "" is a deliberate deletion and must be honored.

applyPatch

Apply a unified diff.

{ args: { operations: Array<{ type: 'create_file' | 'update_file' | 'delete_file'; path: string; diff?: string }>; dry_run?: boolean } }
{ applied: boolean; dry_run: boolean; operations: Array<{ type: string; path: string; status: 'ok' | 'skipped' | 'error'; error?: string }>; rollback_token?: string; warnings?: string[] }

runCommand

Run a shell command. Output is captured and rate-limited per step; overflow spills via readCommandOutput. Novo applies a small pre-dispatch blocklist for obvious shell footguns such as destructive root deletes, disk formatting, and power-control commands. Blocked commands are not sent to your endpoint and surface to the model as command_blocked. Your adapter should still enforce its own sandbox, allow/deny policy, resource limits, and audit logging.

{ args: { command: string; cwd?: string; timeout_ms?: number; env?: Record<string,string> } }
{
  command: string;
  cwd: string;
  exit_code: number;
  signal?: string;
  stdout: string;
  stderr: string;
  stdout_truncated: boolean;
  stderr_truncated: boolean;
  stdout_lines_truncated?: number;
  stderr_lines_truncated?: number;
  duration_ms: number;
  timed_out: boolean;
  output_file_path?: string;
  output_truncated?: boolean;
  metadata?: Record<string, unknown>;
}

readCommandOutput

Page through clipped output from a prior runCommand. Pass the opaque output_file_path back unchanged.

{ args: { path: string; start_line?: number; end_line?: number; max_bytes?: number } }
{
  path: string;
  content: string;
  encoding: 'utf8';
  bytes: number;
  total_lines: number;
  total_lines_partial?: boolean;
  start_line: number;
  end_line: number;
  truncated: boolean;
}

restore

Restore the workspace to a snapshot.

{ args: { token: string } }
null | {}

writeRuntimeArtifact

Write a tool-result spill to the reserved artifact path.

{ args: { runId: string; stepNumber: number; toolCallId: string; mediaType: 'application/json' | 'text/plain'; contents: string } }
{ path: string; bytes_written: number }

Novo derives the path as ./.novo/artifacts/{runId}/{stepNumber}-{sha256(toolCallId).slice(0,16)}.{json|txt}. Your endpoint may persist it however it likes.

getRunArtifacts

List run artifacts.

{ args: {} }
Array<{ path: string; change: 'created' | 'modified' | 'deleted'; bytes: number }>

listExternalFileChanges

Report files modified by you (not the agent) since run start.

{ args: {} }
Array<
  | { kind: 'changed-with-diff'; path: string; snippet: string }
  | { kind: 'changed-no-diff'; path: string; reason: 'binary' | 'partial-prior-read' | 'snippet-too-large' | 'deleted' }
>

Idempotency

Mutating methods (editFile, applyPatch, runCommand, restore, writeRuntimeArtifact) carry a stable Novo-Request-Id header from Novo. Your endpoint should cache by this key, because Novo retries on timeout and reuses the same key.

Update

await novo.environments.update(env.id, { url: 'https://workspace.acme.dev/novo-v2' });

Partial. Bumps configVersion; future runs use the updated adapter config. Use rotateSecret for the secret; update does not touch it.

Errors

  • 404 environment_not_found: bad ID.
  • 400 invalid_request_error: bad URL or wrong type.
  • 403 permission_denied: wrong workspace.