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 wrongtype.403 permission_denied: wrong workspace.