| title | VisionClaw REST API Integration Guide | ||||||
|---|---|---|---|---|---|---|---|
| description | Practical guide to integrating with VisionClaw's REST API — authentication, common workflows, error handling, pagination, and WebSocket combination patterns | ||||||
| category | how-to | ||||||
| tags |
|
||||||
| updated-date | 2026-04-09 |
This guide shows how to accomplish real tasks with the VisionClaw REST API. For raw endpoint listings, see REST API Reference. For WebSocket binary frame format, see WebSocket Binary Protocol.
- VisionClaw running locally or deployed. See deployment-guide.md.
- A Nostr keypair. Generate one with
nostr-tools(see Section 2) or a browser extension such as Alby or nos2x. - Any HTTP client:
curl,fetch,axios, or similar.
Base URLs:
| Environment | REST API | WebSocket |
|---|---|---|
| Development | http://localhost:4000 (direct) or http://localhost:3001 (nginx) |
ws://localhost:4000/wss |
| Production | https://<your-host> |
wss://<your-host>/wss |
All REST paths are prefixed with /api/. The OpenAPI UI is available at http://localhost:4000/swagger-ui/.
graph TD
API[VisionClaw API\nlocalhost:4000] --> Graph[/api/graph/*\nNodes · Edges · Data]
API --> Settings[/api/settings/*\nUser Preferences]
API --> Ontology[/api/ontology/*\nOWL Query · Update]
API --> Admin[/api/admin/*\nSync · Force-sync]
API --> Agents[/api/agents/*\nStatus · Run Skills]
API --> Analytics[/api/analytics/*\nGPU Metrics]
API --> Solid[/solid/*\nPod Resources]
API --> Health[/health\nService Status]
Figure: VisionClaw API endpoint groups — all paths are served from port 4000 (or via nginx on :3001)
VisionClaw uses NIP-98 HTTP authentication. Each mutating request requires a signed Nostr event embedded in the Authorization header. After the first authenticated request the server issues a session token, allowing subsequent requests to use the lighter Bearer scheme.
sequenceDiagram
participant App as Client App
participant Nostr as nostr-tools
participant API as VisionClaw API :4000
participant Store as Oxigraph (embedded)
App->>Nostr: signEvent(kind:27235, url, method, payload_hash)
Nostr-->>App: signed event (Schnorr sig)
App->>App: base64url encode event
App->>API: GET /api/graph/data\nAuthorization: Nostr <b64>
API->>API: Decode + verify Schnorr sig
API->>API: Check timestamp ≤60s
API->>API: Check payload SHA-256 tag
API->>Store: Execute graph query (SPARQL)
Store-->>API: Graph data
API-->>App: 200 JSON response
Figure: NIP-98 authentication sequence — every mutating request carries a freshly signed Nostr event
import { generatePrivateKey, getPublicKey } from 'nostr-tools'
const privateKey = generatePrivateKey() // 32-byte hex
const publicKey = getPublicKey(privateKey)
// Store these securely. Never commit them to version control.import { finishEvent } from 'nostr-tools'
import { createHash } from 'crypto'
function sha256Hex(body: string): string {
return createHash('sha256').update(body).digest('hex')
}
function createAuthEvent(
url: string,
method: string,
privateKey: string,
body?: string
) {
const tags: string[][] = [
['u', url],
['method', method.toUpperCase()],
]
if (body) {
tags.push(['payload', sha256Hex(body)])
}
return finishEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags,
content: '',
},
privateKey
)
}Required constraints (server enforces these):
created_atmust be within 60 seconds of server time.- Each event is single-use — replay protection is enforced server-side.
- POST/PUT requests must include a
payloadtag containing the SHA-256 hex hash of the request body.
const BASE_URL = 'http://localhost:4000/api'
// After first NIP-98 auth the server returns a session token.
// Store it and reuse via Bearer to avoid signing every request.
let sessionToken: string | null =
localStorage.getItem('nostr_session_token')
async function apiCall(
endpoint: string,
method = 'GET',
body?: object
): Promise<unknown> {
const url = `${BASE_URL}${endpoint}`
const bodyStr = body ? JSON.stringify(body) : undefined
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Nostr-Pubkey': publicKey,
}
if (sessionToken) {
headers['Authorization'] = `Bearer ${sessionToken}`
} else {
const authEvent = createAuthEvent(url, method, privateKey, bodyStr)
headers['Authorization'] = `Nostr ${btoa(JSON.stringify(authEvent))}`
}
const response = await fetch(url, {
method,
headers,
body: bodyStr,
})
// Capture session token from first successful auth
const newToken = response.headers.get('X-Session-Token')
if (newToken) {
sessionToken = newToken
localStorage.setItem('nostr_session_token', newToken)
}
if (!response.ok) {
const err = await response.json().catch(() => ({ error: response.statusText }))
const error = Object.assign(new Error(`API ${response.status}`), {
status: response.status,
body: err,
})
throw error
}
return response.json()
}Session token details: Tokens are stored in localStorage as nostr_session_token. Expiry is controlled by the AUTH_TOKEN_EXPIRY environment variable (default: 3600 seconds). On expiry, re-authenticate with a fresh NIP-98 event.
Dev bypass: Set SETTINGS_AUTH_BYPASS=true on the server to treat all requests as dev-user. For local development only.
const graph = await apiCall('/graph/data') as {
nodes: { id: string; label: string; node_type: string; metadata: object }[]
edges: { id: string; source: string; target: string; relationship: string; weight: number }[]
node_count: number
edge_count: number
}
// Filter to a single type
const knowledgeOnly = await apiCall('/graph/data?graph_type=knowledge')
// Lightweight stats check before committing to a full download
const stats = await apiCall('/graph/stats')
console.log(`Graph has ${stats.node_count} nodes and ${stats.edge_count} edges`)Node IDs are sequential u32 values starting at 1. Always use String() coercion when comparing IDs — never === on raw numbers.
// Read all settings (works anonymously; returns user-specific settings when authenticated)
const settings = await apiCall('/settings')
// Update a single key
await apiCall('/settings/physics.damping', 'PUT', { value: 0.85 })
// Bulk update multiple keys in one round-trip
await apiCall('/settings/bulk', 'POST', {
changes: [
{ key: 'physics.damping', value: 0.85 },
{ key: 'physics.spring', value: 0.3 },
{ key: 'rendering.maxNodes', value: 500000 },
],
})
// User-specific filter settings
await apiCall('/settings/user/filter', 'PUT', {
enabled: true,
quality_threshold: 0.8,
authority_threshold: 0.6,
filter_by_quality: true,
filter_by_authority: false,
filter_mode: 'or',
max_nodes: 5000,
})// List all OWL classes
const { classes, total } = await apiCall('/ontology/classes') as {
classes: { iri: string; label: string; subclassOf: string | null }[]
total: number
}
// Retrieve the full hierarchy up to depth 3
const hierarchy = await apiCall('/ontology/hierarchy?max-depth=3')
// Run the Whelk EL++ reasoner to infer new axioms
const inferred = await apiCall('/ontology/classify', 'POST') as {
'inferred-axioms': { axiomType: string; subjectIri: string; objectIri: string; confidence: number }[]
'reasoning-time-ms': number
}// Incremental sync (respects SHA1 cache — only re-processes changed files)
await apiCall('/admin/sync/trigger', 'POST')
// Force full re-sync regardless of SHA1 cache
await apiCall('/admin/sync/force', 'POST')
// Check sync status
const syncStatus = await apiCall('/admin/sync/status')
console.log(`Last sync: ${syncStatus.last_sync_at}, status: ${syncStatus.status}`)Only files tagged public:: true become knowledge graph page nodes. Ontology blocks (### OntologyBlock) are extracted from all files.
// List registered bots
const { bots } = await apiCall('/bots') as {
bots: { id: string; name: string; pubkey: string }[]
}
// Register a new bot
const bot = await apiCall('/bots/register', 'POST', {
name: 'knowledge-curator',
description: 'Automated knowledge graph curation agent',
pubkey: publicKey,
})
// Submit a brief and spawn role agents
const brief = await apiCall('/briefs', 'POST', {
briefing: {
content: 'Analyse the design patterns subgraph for clustering opportunities',
roles: ['analyst', 'curator', 'reviewer'],
},
user_context: { display_name: 'John', pubkey: publicKey },
}) as { brief_id: string; bead_id: string; role_tasks: object[] }
// Retrieve the consolidated debrief
await apiCall(`/briefs/${brief.brief_id}/debrief`, 'POST', {
role_tasks: brief.role_tasks,
user_context: { display_name: 'John', pubkey: publicKey },
})// Shortest path between two nodes
const path = await apiCall('/analytics/pathfinding/42/99')
// { path: [42, 17, 99], distance: 2.0, computation_time_ms: 8 }
// Single-source shortest paths from node 0 (GPU-accelerated)
const sssp = await apiCall('/analytics/pathfinding/sssp', 'POST', {
sourceIdx: 0,
maxDistance: 5.0, // omit for full-graph
})
// sssp.result.distances[i] = distance from node 0 to node i; Infinity means unreachable
// Connected components detection
const cc = await apiCall('/analytics/pathfinding/connected-components', 'POST', {
maxIterations: 100,
})
console.log(`Graph has ${cc.result.numComponents} components`)
// Check whether GPU features are compiled in before issuing GPU calls
const flags = await apiCall('/analytics/feature-flags')
if (!flags.gpu_enabled) console.warn('GPU analytics unavailable — falling back')The recommended pattern for real-time graph visualisation: load the static graph topology via REST, then stream live physics positions over the binary WebSocket.
graph LR
subgraph "Initial Load"
REST[REST\nGET /api/graph/data] --> Render[Initial\n3D Render]
end
subgraph "Live Updates"
WS[WebSocket\nws://host/wss] --> Binary[Binary V3\n52 bytes/node]
Binary --> SAB[SharedArrayBuffer\nPosition Updates]
SAB --> RAF[requestAnimationFrame\nSmooth Animation]
end
Render --> RAF
Figure: REST + WebSocket combination — REST delivers topology once, WebSocket streams position updates continuously
// Step 1 — load topology via REST
const { nodes, edges } = await apiCall('/graph/data') as {
nodes: { id: string; label: string; node_type: string }[]
edges: { source: string; target: string }[]
}
renderGraph(nodes, edges)
// Step 2 — stream live positions via WebSocket
const token = localStorage.getItem('nostr_session_token')
const ws = new WebSocket(
`ws://localhost:4000/wss${token ? `?token=${token}` : ''}`
)
ws.onopen = () => {
// Authenticate over the socket (required if not passing token in URL)
ws.send(JSON.stringify({ type: 'authenticate', token, pubkey: publicKey }))
// Subscribe to position updates
ws.send(JSON.stringify({ type: 'subscribe_position_updates' }))
}
ws.onmessage = (event: MessageEvent) => {
if (typeof event.data === 'string') {
// JSON control frame (state_sync, subscription_confirmed, heartbeat…)
const msg = JSON.parse(event.data)
if (msg.type === 'heartbeat') ws.send(JSON.stringify({ type: 'heartbeat' }))
return
}
// Binary frame — single binary protocol: 1 preamble byte (0x42) + 8-byte LE
// broadcast_sequence + 24 bytes/node. See docs/binary-protocol.md.
const buffer = event.data as ArrayBuffer
const view = new DataView(buffer)
const preamble = view.getUint8(0)
if (preamble !== 0x42) {
console.warn(`Unexpected WS binary preamble: 0x${preamble.toString(16)}`)
return
}
const HEADER_SIZE = 9 // 1 preamble + 8 broadcast_sequence
const NODE_SIZE = 24 // u32 id + 6 × f32 (pos+vel)
const nodeCount = (buffer.byteLength - HEADER_SIZE) / NODE_SIZE
for (let i = 0; i < nodeCount; i++) {
const offset = HEADER_SIZE + i * NODE_SIZE
const nodeId = view.getUint32(offset, true)
const x = view.getFloat32(offset + 4, true)
const y = view.getFloat32(offset + 8, true)
const z = view.getFloat32(offset + 12, true)
updateNodePosition(String(nodeId), x, y, z)
}
}
// Send a heartbeat every 25 seconds to keep the connection alive
setInterval(() => ws.send(JSON.stringify({ type: 'heartbeat' })), 25_000)The server emits one binary protocol — there are no versions. The preamble byte (0x42) is a fixed sanity check, not a version dispatch. Sticky GPU outputs (cluster_id, community_id, anomaly_score, sssp_distance, sssp_parent) ride a separate analytics_update JSON message at recompute cadence. See docs/binary-protocol.md and ADR-061.
VisionClaw returns structured error bodies for all 4xx/5xx responses.
interface ApiError {
error: string // human-readable message
code?: string // VisionClaw error code, e.g. "AP-E-305"
details?: Record<string, unknown>
}Error code format: [SYSTEM]-[SEVERITY]-[NUMBER]. Key codes for integrators:
| Code | Meaning | Action |
|---|---|---|
AP-E-101 |
Missing auth token | Add Authorization header |
AP-E-102 |
Invalid or expired token | Re-authenticate with a new NIP-98 event |
AP-E-103 |
Session token expired | Re-authenticate; issue new NIP-98 event |
AP-E-305 |
Rate limit exceeded | Exponential back-off; check Retry-After header |
AP-E-307 |
Operation timeout | Retry; consider reducing request scope |
AP-E-201 |
Resource not found | Verify the ID exists via a GET first |
async function apiCallWithRetry(
endpoint: string,
method = 'GET',
body?: object,
maxRetries = 3
): Promise<unknown> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await apiCall(endpoint, method, body)
} catch (err: unknown) {
const e = err as { status?: number; body?: ApiError }
if (e.status === 429) {
// Rate limited — exponential back-off
const delay = Math.pow(2, attempt) * 1000
await new Promise(resolve => setTimeout(resolve, delay))
continue
}
if (e.status === 401) {
// Token expired — clear and force NIP-98 re-auth on next call
sessionToken = null
localStorage.removeItem('nostr_session_token')
continue
}
throw err
}
}
throw new Error(`Failed after ${maxRetries} attempts: ${endpoint}`)
}/graph/data returns the full graph in one payload. For large datasets use the node-level endpoint with cursor pagination:
interface NodePage {
nodes: { id: string; label: string; node_type: string }[]
next_cursor: string | null
}
async function fetchAllNodes(pageSize = 100): Promise<NodePage['nodes']> {
const nodes: NodePage['nodes'] = []
let cursor: string | null = null
do {
const params = new URLSearchParams({ limit: String(pageSize) })
if (cursor) params.set('cursor', cursor)
const page = await apiCall(`/graph/nodes?${params}`) as NodePage
nodes.push(...page.nodes)
cursor = page.next_cursor
} while (cursor)
return nodes
}For ontology data, use ?max-depth=N on /api/ontology/hierarchy to cap response size rather than paginating.
There is no official SDK. The apiCall helper in Section 2 is sufficient for most integrations. For a more structured client, the minimal pattern below wraps all endpoint groups:
class VisionClawClient {
constructor(
private base: string,
private privateKey: string,
private publicKey: string
) {}
private async call(path: string, method = 'GET', body?: object) {
// ... same logic as apiCall above, using this.base / this.privateKey
}
graph = { data: (type?: string) => this.call(`/graph/data${type ? `?graph_type=${type}` : ''}`),
stats: () => this.call('/graph/stats'),
node: (id: string) => this.call(`/graph/node/${id}`) }
settings = { getAll: () => this.call('/settings'),
update: (key: string, val: unknown) => this.call(`/settings/${key}`, 'PUT', { value: val }),
bulkUpdate: (changes: object[]) => this.call('/settings/bulk', 'POST', { changes }),
userFilter: (f: object) => this.call('/settings/user/filter', 'PUT', f) }
ontology = { classes: () => this.call('/ontology/classes'),
hierarchy: (depth?: number) => this.call(`/ontology/hierarchy${depth ? `?max-depth=${depth}` : ''}`),
classify: () => this.call('/ontology/classify', 'POST') }
sync = { trigger: () => this.call('/admin/sync/trigger', 'POST'),
force: () => this.call('/admin/sync/force', 'POST'),
status: () => this.call('/admin/sync/status') }
bots = { list: () => this.call('/bots'),
register: (b: object) => this.call('/bots/register', 'POST', b) }
}| Endpoint group | Limit | Window | Error code |
|---|---|---|---|
| All endpoints (default) | 100 req | 60 s | AP-E-305 |
POST /ontology/classify |
10 req | 60 s | AP-E-305 |
POST /analytics/pathfinding/sssp |
20 req | 60 s | AP-E-305 |
POST /analytics/pathfinding/apsp |
5 req | 60 s | AP-E-305 |
POST /admin/sync/* |
2 req | 300 s | AP-E-305 |
GET /graph/data |
60 req | 60 s | AP-E-305 |
When rate-limited the server responds with HTTP 429 and a Retry-After header indicating seconds to wait. Use exponential back-off as shown in Section 5.
For ad-hoc testing, generate a signed NIP-98 event from the command line using Node.js and nostr-tools:
# Install nostr-tools globally if not present
npm install -g nostr-tools
# Set your private key (hex)
SK="your_private_key_hex_here"
TARGET_URL="http://localhost:4000/api/graph/data"
METHOD="GET"
AUTH=$(node -e "
const { finishEvent, getPublicKey } = require('nostr-tools')
const sk = process.env.SK
const pk = getPublicKey(sk)
const event = finishEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [['u', process.env.TARGET_URL], ['method', process.env.METHOD]],
content: '',
pubkey: pk,
}, sk)
console.log(Buffer.from(JSON.stringify(event)).toString('base64'))
" SK="$SK" TARGET_URL="$TARGET_URL" METHOD="$METHOD")
curl -s \
-H "Authorization: Nostr $AUTH" \
-H "X-Nostr-Pubkey: $(node -e "const {getPublicKey}=require('nostr-tools');console.log(getPublicKey('$SK'))")" \
"$TARGET_URL" | jq .For POST requests add a body hash to the payload tag and pass -d to curl:
BODY='{"changes":[{"key":"physics.damping","value":0.9}]}'
TARGET_URL="http://localhost:4000/api/settings/bulk"
METHOD="POST"
AUTH=$(node -e "
const { finishEvent, getPublicKey } = require('nostr-tools')
const { createHash } = require('crypto')
const sk = process.env.SK
const pk = getPublicKey(sk)
const body = process.env.BODY
const hash = createHash('sha256').update(body).digest('hex')
const event = finishEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [['u', process.env.TARGET_URL], ['method', 'POST'], ['payload', hash]],
content: '',
pubkey: pk,
}, sk)
console.log(Buffer.from(JSON.stringify(event)).toString('base64'))
" SK="$SK" TARGET_URL="$TARGET_URL" BODY="$BODY")
curl -s -X POST \
-H "Authorization: Nostr $AUTH" \
-H "Content-Type: application/json" \
-d "$BODY" \
"$TARGET_URL" | jq .Dev bypass (local only): skip all auth by setting SETTINGS_AUTH_BYPASS=true on the server, then omit the Authorization header entirely.
401 — Missing authorization token
The Authorization header is absent or malformed. Ensure the header value is Nostr <base64> for NIP-98 or Bearer <token> for session auth.
401 — Token is invalid or expired (AP-E-102/103)
The NIP-98 event's created_at is outside the 60-second window, or the session token has expired (default 1 hour). Regenerate the event with the current timestamp. Re-authenticate to get a fresh session token.
401 on a replay attempt
VisionClaw enforces single-use events. Each request must use a freshly signed event with a new created_at.
403 — Access forbidden
The authenticated pubkey lacks the required permission. Power-user actions (sync, classify, bulk settings) require the pubkey to be listed in POWER_USER_PUBKEYS on the server.
POST/PUT returns 400 with "missing payload tag"
Mutation requests must include a payload tag in the NIP-98 event containing the SHA-256 hex hash of the request body. See Section 2.2.
WebSocket disconnects immediately after upgrade
Ensure the token is either passed as ?token= in the WebSocket URL or sent as an authenticate JSON message within the first few seconds of connection. See Section 4.
GPU analytics return "GPU features not enabled"
The binary was compiled without the gpu feature flag, or no CUDA-capable GPU is present. Check /api/analytics/feature-flags before issuing GPU endpoint calls.
Sync returns stale data
Use POST /api/admin/sync/force to bypass the SHA1 incremental cache, or set FORCE_FULL_SYNC=1 in the server environment and restart.
Clock skew causes consistent 401s
NIP-98 requires created_at within 60 seconds of server time. If the client clock is skewed, synchronise it via NTP or fetch server time from a /api/health endpoint before signing events.