Interactive schema: http://127.0.0.1:8787/docs; machine schema: /openapi.json.
Generate an owner-level connector key from Settings. Send it as Authorization: Bearer YOUR_CONNECTOR_KEY. The API never uses a Telegram password as a bearer credential. Never embed keys in URLs.
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8787/v1", api_key="YOUR_CONNECTOR_KEY")
for chunk in client.chat.completions.create(
model="deepseek-flash",
messages=[{"role": "user", "content": "Explain this function."}],
stream=True,
stream_options={"include_usage": True},
extra_body={"thinking": {"type": "enabled"}},
):
print(chunk)GET /v1/models forwards the current DeepSeek model list. POST /v1/chat/completions preserves extra request fields, upstream JSON/SSE content, reasoning, tool-call deltas and errors. It is a provider proxy: it does not execute client-supplied tools. Supply X-Session-ID to attribute usage to an existing AIGent session; otherwise it uses an API accounting session. Streaming callers must request stream_options.include_usage to obtain usage. Provider-specific extensions are passed through, not translated. Responses/Anthropic/MCP interfaces are planned.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/sessions |
Create a web session: {"title":"My task"} |
| GET | /api/sessions |
Session list and usage totals |
| POST | /api/sessions/{id}/topics |
Create a Telegram topic based on an existing chat session |
| POST | /api/sessions/{id}/messages |
Start agent turn: {"text":"..."}, returns 202 |
| GET | /api/sessions/{id}/events?after=0 |
Ordered JSON event journal, pages of 500 |
| GET | /api/sessions/{id}/stream |
SSE with event IDs, Last-Event-ID resume and keepalives |
| POST | /api/sessions/{id}/stop |
Cancel current turn |
| GET | /api/approvals |
Pending concrete file/command reviews |
| POST | /api/approvals/{id} |
{"accepted":true} or false |
| GET | /api/sessions/{id}/files |
Workspace file listing |
| GET | /api/sessions/{id}/file?path=... |
Download a workspace artifact |
| POST | /api/sessions/{id}/files |
Multipart upload, optional Telegram delivery and agent processing |
| GET | /api/sessions/{id}/media?path=...&variant=original|thumb|preview|poster |
The untouched file (default), a cached thumbnail, a browser-ready preview or a poster frame. 404 when missing, 415 when damaged or not media |
| GET | /api/sessions/{id}/image?path=... |
The same file, image or video, for the plain preview |
| POST | /api/sessions/{id}/file-path |
{"path":"..."} → absolute path, for the desktop clipboard |
| GET | /api/sessions/{id}/async-questions |
Open non-blocking questions asked with ask_user_async |
| POST | /api/sessions/{id}/async-questions/{qid} |
{"answer":"..."} answers one (empty answer dismisses it) |
| GET | /api/update |
Running build against the published release |
| GET | /api/sessions/{id}/media/info?path=... |
Probe data: kind, size, width, height, duration, codec |
| GET | /api/media/capabilities |
{"ffmpeg":bool,"ffprobe":bool,"path":"..."} for the interface |
| GET | /api/sessions/{id}/media/telegram?path=... |
The file, re-downloaded from Telegram by file_id when the local copy is gone |
| POST | /api/sessions/{id}/media/evict |
{"path":"..."} deletes the local copy; refused without a stored file_id |
| GET | /api/sessions/{id}/sync |
Mirror state: chat_id, topic_id, topic_url, last_mirrored_event, media_count, media_in_telegram, pending_uploads |
| POST | /api/sync/backfill |
Create the missing topics for every non-deleted session; idempotent, ≤ 1 request/s |
| POST | /api/sessions/{id}/telegram |
Structured messages: text, location, venue, contact, poll, dice |
| GET | /api/sessions/{id}/usage |
Per-request accounting and session totals |
| GET | /api/balance |
Shared DeepSeek API account balance |
| GET | /api/status |
Poller state, model, aggregate usage |
| POST | /api/maintenance/cleanup |
Run transient-row cleanup immediately: body {"days": int?} (omitted → config cleanup_days); returns per-category delete counts and whether VACUUM ran |
Upload fields: file, kind (document/photo/audio/voice/video/video_note/animation/sticker), caption, send_telegram and ask_agent. Delivery always targets the selected session, never a caller-supplied chat ID. A web-only session has no Telegram destination. Any binary format can be stored as a document, subject to size limits.
A background task removes transient rows so the journal does not grow forever. Every cleanup_interval_hours (default 6) it deletes stream events, sent/cancelled queued_messages and closed async_questions older than cleanup_days (default 14); the first run is 60 s after startup. A VACUUM runs only when more than 1000 rows were removed. The job logs via print and writes no session events. POST /api/maintenance/cleanup runs it on demand.
Turn any failure into a fix chat that repairs the code and closes itself.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/sessions/{id}/heal |
{"error_ref":<event id>?} → create a fix chat and start it; returns {"fix_sid","source_sid","session"} |
| GET | /api/sessions/{id}/heal |
Fix chats spawned from this source, with status |
| GET | /api/heal |
All fix chats: {fix_sid,source_sid,error_ref,status,title,archived,...} |
| POST | /api/heal/{fix_sid}/confirm |
{"verified":true,"restart":false} → archive the fix chat and note both sides; verified:false keeps it open |
| POST | /api/heal/{fix_sid}/restart |
Explicit, user-confirmed restart request; refused while another turn is running |
The fix chat inherits the source's provider, account, model, effort, project and resolved workspace,
so it edits the same codebase. It knows the prior context without copying the whole history: a compact
briefing is seeded as the first user message from the source's last goal, its recent tool actions and the
specific error (the newest error + trace, or the event named by error_ref). The error text is fenced
as DATA — never treated as instructions to the fixer. The turn starts immediately (agent.submit).
status moves open → archived on confirm (verified is a transient marker). Confirming sets
sessions.archived=1 for the fix chat — recoverable, never deleted — and posts a closing note into both
the fix and the source session.
Restart is gated: it is refused while any other session's turn is running, and it is only ever triggered on
an explicit request (the UI confirms first) — never automatically from observed error text. With
selfheal_restart_command set in config it runs that command; otherwise it sets the store state flag
restart_requested for the supervisor or desktop shell to pick up.
Automation (off by default): with auto_confirm_fixes true, a fix chat whose turn ends after a
run_command/exec_command result shows the project checks passing (N passed / All checks passed,
exit code 0, no N failed) is auto-confirmed and archived. It never auto-restarts.
Settings keys: telegram_sync_chat_id (the -100… id of a supergroup with topics enabled; empty = off),
telegram_sync (default true once the chat id is set), telegram_media_offload (default false),
telegram_media_offload_mb (default 20).
Each session gets one forum topic. POST /api/sessions and POST /api/sessions/{id}/messages create it on
demand, PATCH /api/sessions/{id} with a new title renames it, DELETE /api/sessions/{id} closes it, and
/api/sessions reports the per-session telegram block for a badge. Outbound text (IDE messages, assistant
answers, errors, one grouped tool summary per turn) is plain text split at 4096 characters; a message written
in the topic by an authorized user continues that same session. Every mirrored call is sequential per chat,
retried on 429 with the Telegram retry_after, and runs on a bounded background queue, so a slow or broken
Telegram never blocks a turn — the failure is journaled once per session as a notice.
Media: a media event with a workspace path uploads the file to the topic and records
media_files(session_id, path, sha256, file_id, file_unique_id, kind, size, message_id, uploaded_at). The
per-session mirror pointer lives in sync_state(session_id, chat_id, topic_id, header_message_id, last_event_id, status) and makes mirroring exactly-once across restarts. The Bot API cannot enumerate a
topic's history, so this sqlite index is the authoritative list of stored file_id values — back it up.
/api/sessions/{id}/media serves derived files from a per-session cache under .local/media-cache/<session>/.
Cache keys include the source path, mtime and size, so every answer carries
Cache-Control: private, max-age=31536000, immutable and a changed file produces a new key.
Only image, video and audio files are served; anything else answers 415, an unsafe path 400.
thumb— 320px WebP (Pillow) or a video frame at ~10 % of the duration, awaited in the request.preview— image up to 1600px, or H.264/AAC MP4 up to 720p with+faststart. An MP4 that is already H.264/AAC at 720p or less is served untouched. While a transcode runs the endpoint answers202 {"status":"processing"}; poll the same URL (HEAD is supported, so polling costs no download).poster— a full-size video frame for the player; for an image it equalspreview.original— the source file with HTTP Range support, so a browser can seek inside a clip.
At most two ffmpeg jobs run at once, a job per cache key is shared between concurrent requests, and a job is
stopped after media_transcode_timeout_seconds (default 600). Settings keys: ffmpeg_path (empty = discover
on PATH and in common Windows locations), media_transcode_timeout_seconds, media_cache_mb (default 500,
LRU eviction inside the session cache). Without ffmpeg images still work and video is served as original.
Structured send example: {"kind":"location","payload":{"latitude":55.0,"longitude":82.9}}. Unknown Bot API fields inside supported message payloads are preserved. Incoming unsupported message structures are journaled as telegram_payload.
GET /api/sessions/{id}/goal returns the objective the session is pursuing —
{goal, kind, status: active|blocked|done, steps: [{text, status}], note, updated, source, auto_continue} — and
POST the same path edits it (any subset of those fields; the edit reaches the model in the next turn
note). The agent maintains it from the set_goal tool and from update_plan, and emits a goal event on
every real change. auto_continue (the session flag, also set with PATCH /api/sessions/{id}) lets a turn that ended with
the goal still active continue by itself, at most max_auto_continues times per user message, and never
after an error, a cancellation, a pending approval or a blocking question. GET /api/sessions/{id}/context additionally reports
cache: the hit/miss tokens and hit rate of the last request, or known: false when the API did not say.
The main DeepSeek agent delegates code by default with the spawn_subagents tool:
{"tasks": [{"goal", "files"?, "context"?, "score"?}], "shared_context"?} (at most 8 tasks;
score 0..1 is a dispatch priority). Each worker is a real subagent with its own conversation
and step budget (subagent_max_steps, default 40, cap 200) and the session's workspace and
execution tools (list_files, read_file, search_files, write_file, apply_patch,
run_command, exec_command, write_stdin); it runs in the session, so its writes and commands
go through the same approval as the main agent's, and it cannot ask the owner, publish a goal or
spawn workers. Workers run in parallel up to subagent_concurrency (default 4, hard cap 8) with
subagent_max_tokens output per call (default 8192). Every worker of a batch sends one
byte-identical prefix first — the worker instruction, the project guidance and
shared_context — so the prompt cache serves it for the whole fan-out. Set
subagents_enabled: false to hide the tool entirely.
Each result carries status (done, exhausted, failed, cancelled), the plain-text
report, files the worker changed, steps, tokens, cache_hit, cache_miss, cost_usd
and seconds; the batch adds totals with the union of changed files.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/sessions/{id}/subagents |
Live snapshot: {subagents:[…], totals:{count,total,total_tokens,total_cost_usd,total_steps}} |
| POST | /api/sessions/{id}/subagents |
Debug spawn: {tasks:[{goal,…}], shared_context?} (the agent normally spawns via the tool) |
| POST | /api/sessions/{id}/subagents/cancel |
Stop every in-flight worker of the session |
The panel updates live from subagent events (phase: spawn|update|done), each carrying the
worker's id, unique emoji + colour, brief goal, status, current tool (activity, detail),
steps, files, log, report, runtime, tokens and cost. Settings keys: subagents_enabled,
subagent_concurrency, subagent_max_tokens, subagent_max_steps.
engine (settings, dsh or legacy) selects what runs a DeepSeek session's turns. With dsh
the connector drives one DeepSeek Harness runtime per session and offers its own tools to it over
MCP at POST /api/mcp/{sid} (Streamable HTTP; JSON-RPC initialize, tools/list, tools/call,
ping; authenticate with the connector token; GET answers 405). GET /api/engine reports the
live runtimes: {engine, available, runtimes, sessions:{sid:{dsh_session, prompts, idle_seconds}}}.
dsh_idle_minutes (default 20) closes a runtime nobody used; the next turn starts a new one.
restart_server {reason} (agent tool) schedules a restart of the server on its current code: the
request is a flag in state, the restart happens after the last running turn ends, a verification
message is queued for the requesting session first, and the process exits with code 3 for the
supervisor to respawn it. Refused without run.py --supervise. The self-heal restart endpoint
sets the same flag. server_status {} (agent tool) returns the version, uptime, whether a
supervisor runs the process, the safe fields of supervisor.json (state, revision, restarts,
failures, good_revision, restored) and whether a restart is pending — the evidence the
verification turn after a restart is asked for.
Agent tools chrome_open {url}, chrome_snapshot, chrome_click {ref|selector|text},
chrome_type {ref|selector|text, input, clear?, press_enter?}, chrome_press {key},
chrome_upload {ref|selector|text, path}, chrome_wait {text?, selector?, url_contains?, timeout_seconds?},
chrome_screenshot, chrome_eval {expression} drive AIGent's Chrome window over DevTools (one tab
per session; its own profile directory, the owner signs in there once). Approvals: launching Chrome,
uploads, eval. Settings: chrome_debug_port, chrome_user_data_dir, chrome_profile.
max_steps is a checkpoint, not a limit: while the session's auto_continue is on, a turn
passes it with a notice event (checkpoint, ceiling) and keeps working, up to
max_turn_steps model calls (default 200), where it ends with a summary and schedules its
continuation. With auto_continue off the checkpoint ends the turn. A message queued during a
turn joins the conversation at the next step boundary (queue_started with inline: true and a
user event with inline: true) instead of waiting for the turn to end.
Event types include user, stream, assistant, tool, tool_result, approval, decision, approval_closed, media, usage, context, goal, subagent, read_cache, error and notice. Stream events carry cumulative text and provider reasoning, keyed by a stable stream id. Render the latest state instead of appending it as duplicate text.
Desktop preview adds projects/accounts, local native history import, editor revisions, Git, terminal output, plans and user questions. See the running /docs for exact request schemas. POST /api/sessions/{id}/fork creates an independent conversation sharing the source workspace; DELETE /api/sessions/{id} stops and soft-deletes a chat; GET /api/sessions?deleted=true lists Trash and POST /api/sessions/{id}/restore restores it. No project files are removed.
New tool events carry call_id to pair each invocation with its result. turn_completed closes a turn. The UI groups routine steps, combines per-turn usage and retains the complete ordered event journal. Forked historical events carry inherited: true; historical usage is displayed as preceding the fork and is not charged again to the new session.
Provider reasoning is returned provider data, not reasoning authored by the connector. Pricing is estimated and raw usage is retained. Missing cache/cost values are null; aggregate counters report how many requests are unpriced or lack cache metrics.
Remote hosts are configured by the owner through GET/POST /api/settings and stored in config.json:
{"ssh_hosts": [{"id": "prod", "host": "1.2.3.4", "port": 22, "user": "ubuntu",
"identity": "C:/Users/me/.ssh/id_ed25519", "label": "Prod server", "allow_commands": true}],
"ssh_binary": "", "ssh_timeout_seconds": 120}ssh_binary overrides the ssh executable (scp is taken from the same folder); empty means shutil.which, then
%SystemRoot%\System32\OpenSSH\ssh.exe. Omitting an SSH key from a settings request keeps its stored value.
With at least one host configured the agent is offered ssh_hosts (list ids, labels, user@host:port,
allow_commands; no approval), ssh_exec (host_id, command, optional timeout_seconds), ssh_upload
(host_id, workspace-relative path, remote_path) and ssh_download (host_id, remote_path, workspace-relative
path, 50 MB cap). Without hosts the tools are not offered at all. ssh_exec, ssh_upload and ssh_download each
require an administrator approval, like run_command; a host with allow_commands: false refuses ssh_exec without
asking. Authentication is key-based only: BatchMode=yes, StrictHostKeyChecking=accept-new, ConnectTimeout and
ServerAliveInterval are always passed, no password is ever accepted or stored, and no interactive prompt can appear.
Results carry exit_code, merged output (24000 characters, redacted), timed_out and host_id; a timeout kills the
client and returns partial output. Exit code 255 also journals a notice event: check host, key, and that the key is
authorized on the server.
Read-only web tools, offered to the agent without any approval and controlled by settings:
{"allow_web": true, "web_search_url": "https://html.duckduckgo.com/html/?q={query}",
"web_allow_private": false, "browser_binary": "", "browser_timeout_seconds": 60}web_fetch(url, optionalmax_chars200…60000, default 20000) — one GET with httpx: 30 s timeout, redirects followed manually so every hop is re-checked, 5 MB body cap,User-Agent: AIGent/0.3. HTML becomes readable text (script/style/nav/noscript dropped, headings kept as#, up to 60 absolute links kept as[text](href)); JSON and plain text are returned as they are. Result:url,final_url,status,content_type,title,text,chars,truncated.web_search(query, optionalmax_results1…10) — fetchesweb_search_urlwith{query}replaced by the URL-encoded query and parses titles, URLs (DuckDuckGo/l/?uddg=redirects unwrapped) and snippets. This is a lightweight scrape of an HTML page, not an official API: it can stop returning results at any time, and then the result carries ahint. Pointweb_search_urlat a self-hosted SearXNG to avoid that.browser_open(url, optionalwidth320…2560 /height200…8000 /full_page/wait_ms0…20000 /include_text) — runs a headless Chromium-family browser (--headless=new --disable-gpu --no-sandbox --hide-scrollbars --window-size --virtual-time-budget --user-data-dir=<root>/browser-profile --screenshot=<file>) and saves a PNG asweb/<timestamp>-<hash>.pnginside the session workspace, so/api/sessions/{id}/media?path=web/…renders it in the chat. A second run with--dump-domsuppliestitleandtext(20000 characters; setinclude_text: falseto skip it).full_pageis emulated by a 4000 px tall window — content below that is still not captured. There is no clicking, typing, scrolling or in-page navigation; the tool description says so. Result:url,title,screenshot,width,height,text,image_delivered.
The browser binary is located in this order: browser_binary, then msedge/chrome/google-chrome/
chromium/chromium-browser on PATH, then the standard Windows Edge and Chrome locations, then
AIGENT_BROWSER (tests). Without a binary browser_open is not offered at all; allow_web: false hides
all three tools, so the system prompt never lists them.
Every URL passes one guard: only http/https (never file:, data:, ftp: or about:), and the host
must resolve to a public address — loopback, private, link-local, multicast and reserved addresses are
refused, on the first request and on every redirect, so the connector's own admin API cannot be reached
through a model-supplied URL. web_allow_private: true lifts only the address check, for an installation
that deliberately browses its own LAN.
web_fetch and web_search journal a web event (url, title, status, chars); browser_open
journals web with the screenshot path plus a media event, so the screenshot is clickable in the chat.
With a vision-capable model the screenshot is also delivered to the model as vision input (media event with
direction vision); otherwise the result explains that the model cannot see it and should use web_fetch.
The map has two halves. Everything that inventories the project is local and free; only three
endpoints spend DeepSeek tokens, and each one records real usage through the usage journal tagged
source: "project_map", so /api/sessions/{id}/usage and the widget show the same numbers.
| Method | Endpoint | Cost | Purpose |
|---|---|---|---|
| GET | /api/sessions/{id}/map |
free | Latest map, job history, active job, memory text, budget |
| GET | /api/sessions/{id}/map/estimate |
free | Fresh inventory (files, languages, git, skills, edges) and the token/cost estimate |
| POST | /api/sessions/{id}/map/probe |
paid: 1 request | Three smallest files in one real request; returns estimated vs actual tokens and stores the calibration |
| POST | /api/sessions/{id}/map/scan |
paid: many requests | {"mode":"summaries","max_cost_usd":0.5}; starts the batch job |
| GET | /api/sessions/{id}/map/jobs |
free | Job list with state, progress, tokens and spend |
| POST | /api/sessions/{id}/map/jobs/{job}/pause|resume|cancel |
free | pause finishes the in-flight request and stops; resume continues from the next batch |
Inventory: a symlink-free walk that skips .git, .local, .venv, node_modules, __pycache__,
Library, Temp, Logs, build, dist and similar, caps text files at 200 KB (larger files and
known binary extensions are counted as binary) and records path, size, lines, language, sha256 and
mtime. Python imports are read with ast, JS/TS imports with a regex, and both are resolved to
in-project files where possible — unresolved names stay in external_imports. Git data comes from one
git subprocess per query with GIT_CEILING_DIRECTORIES set and the same guard as the workspace Git
API: a repository root above the workspace is reported as "no repository" instead of being read.
Remote URLs are stored with credentials replaced by [REDACTED].
Estimate: per file ceil(chars / 3.2) when more than 20 % of the characters are non-ASCII, otherwise
ceil(chars / 4); files are packed into requests of at most 24 000 input tokens (40 files), each
request adds 600 input tokens of instructions and 350 output tokens per file, and one extra request
covers the map summary. Prices come from pricing[model] in the configuration, at the peak rate, and
are reported twice: full cache miss and an 80 % cache hit. The stored calibration factor from the last
probe (actual / estimated input tokens, clamped to 0.2–5) multiplies every later estimate.
Scan job: one non-streaming DeepSeek request per batch asking for strict JSON
(path, purpose, key_symbols, depends_on, tags). A tolerant parser strips code fences; an
answer that still does not parse is kept raw and its files are marked unparsed. Before each batch the
job compares projected spend with max_cost_usd (default project_map_budget_usd, 0.50) and
auto-pauses with the reason when the next request would cross it. Progress is journalled as
map_progress (job_id, done, total, tokens, cost_usd, state) at most every two seconds,
plus map_paused and map_completed. When the batches finish, one more request writes a ≤200-word
project overview and the map is stored: modules grouped by top-level directory, dependency edges
(static and model-reported), related skills and memory notes, the git summary and the accounting.
Nothing is written into the user's project directory.
The stored map also produces memory_context (≤3000 characters). When agent.project_map is set, the
agent appends it to the project guidance of every turn, so the model starts with the map instead of
rediscovering the tree.
Every model file in a session workspace has one JSON sidecar next to it, written into the workspace on
purpose so provenance travels with the model. chair.glb keeps its record in chair.glb.json.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/sessions/{id}/models |
Model files with size, format, parsed stats and sidecar summary. Missing sidecars are created on this call |
| GET | /api/sessions/{id}/models/sidecar?path= |
Full sidecar for one model |
| POST | /api/sessions/{id}/models/sidecar |
{"path":"chair.glb","patch":{...}}; merges and appends to history |
| POST | /api/sessions/{id}/models/reference |
{"path":"chair.glb","image":"ref.png","note":""}; records a workspace-relative reference image |
| GET | /api/sessions/{id}/asset/{path} |
Any workspace file, inline, with its real media type (model/gltf-binary, model/gltf+json, …) |
| GET | /api/graphics/preset?refresh= |
Gravity House graphics record plus derived quality levels |
.glb and .gltf are viewable; .fbx, .obj, .usdz, .ply and .stl are listed with
viewable: false and a note, because the built-in viewer loads glTF only. The patch accepts
prompt, negative_prompt, seed, params, tags, source, title and note; any other field
is refused with 400, so stats, sha256 and history cannot be rewritten from the browser.
Sidecar schema, version aigent.model/1:
{"schema":"aigent.model/1","file":"shared/chair.glb","format":"glb",
"created":1757600000.0,"updated":1757600000.0,"size":184320,"sha256":"…",
"source":{"tool":"autorig","provider":"autorig.online","job_id":"…","url":"…"},
"prompt":"","negative_prompt":"","seed":null,"params":{},
"references":[{"path":"ref.png","kind":"image","note":"","sha256":"…"}],
"history":[{"ts":1757600000.0,"event":"created","detail":"…"}],
"stats":{"meshes":1,"primitives":1,"triangles":11842,"materials":1,"textures":3,"images":3,
"animations":0,"nodes":2,"skins":0,"bounds":{"min":[…],"max":[…],"size":[…]},
"generator":"…","version":"2.0","extensions":[…],"draco":false,"ktx2":false},
"tags":[]}stats is filled server-side by a pure-python reader of the glTF JSON (the JSON chunk of a .glb):
counts come from the document, triangles from the index accessor of every TRIANGLES primitive (or the
POSITION count when a primitive has no indices), and bounds from the POSITION accessor min/max.
No geometry is decoded and no external library is used, so the numbers are what the file declares.
A generator can register a model as soon as it lands in the workspace:
agent.models3d.register(session_id, "shared/chair.glb", {
"source": {"tool": "autorig", "provider": "autorig.online", "job_id": task_id, "url": glb_url},
"prompt": prompt, "seed": seed, "params": {"quality": "high"}})register creates the sidecar if it is missing and merges the metadata into it, keeping any prompt the
user already typed. The upload route calls it for an uploaded .glb/.gltf with
source.tool = "upload". Nothing calls it from shared_tools.py or autorig.py yet: a farm result is
picked up by the next GET /api/sessions/{id}/models with source.tool = "unknown".
GET /api/graphics/preset fetches graphics_preset_url
(default https://autorig.online/gravityhouse/api/graphics) with a 10 s timeout, caches the raw record
to <root>/graphics-preset.json and answers:
{"source":"server|cache|builtin","revision":24,"url":"…","settings":{…},"quality":{"1":{…},"2":{…},"3":{…}}}settings is the viewer-relevant subset of the Unity record, normalised to snake_case: post_enabled,
antialiasing, bloom, color_adjustments, tonemapping, vignette, ssao, sun, environment,
white_balance. Field lookup is case-insensitive and every key the record omits keeps the embedded
revision-24 value, so an unexpected server shape degrades instead of failing. With allow_web off, or
when the server is unreachable and no cache exists, source is "builtin".
Quality levels are derived server-side so the browser never interprets Unity fields:
| Level | Pixel ratio | Shadows | Post chain |
|---|---|---|---|
| 1 | 1.0 | off | none |
| 2 | ≤ 1.5 | PCF 2048, 40 m | tone mapping + vignette |
| 3 | device | PCFSoft 4096, 40 m | SSAO + bloom (only when the record enables them) + vignette + SMAA/FXAA + output pass |
Mesh identities and transforms are never touched by a quality change: only renderer, light and pass settings differ.