Bridge API
The bridge relay is the local HTTP service inside DousenDesktop (BridgeHttpRelay, default 127.0.0.1:8766, overridable via the DOUSEN_BRIDGE_PORT environment variable the launcher sets for spawned DCCs) that fans events out between everything running on one artist's machine: the DCC addons (Blender, Maya, 3ds Max, Houdini, Substance Painter, Substance Designer) and the Unreal Engine plugin. It speaks HTTP/JSON only — there is no gRPC path, and there is no push/websocket transport either: every client is a poller. This page documents its full API, byte-for-byte, so a studio tool — a batch exporter, a custom DCC, a watch-folder script — can participate in the pipeline exactly like the built-in addons do.
Three source files are the ground truth for everything below: src/bridge/BridgeHttpRelay.h/.cpp (the server), src/bridge/EventBroker.h/.cpp (the routing core it wraps), and src/bridge/BridgeEvent.h (the event value type). src/bridge/BridgeServer.h/.cpp is the façade that owns both and wires them into the rest of DousenDesktop (TrayIcon, SourceAssetTracker, MainWindow).
Everything here is local to one artist machine. The relay binds 127.0.0.1 only (QTcpServer::listen(QHostAddress::LocalHost, …)) and is not reachable from the network. For talking to the Dousen server, see the endpoint tables on the Perforce, Naming conventions, and Settings pages.
Authentication & security model
Every request needs a bearer token:
HTTPAuthorization: Bearer <token>
DousenDesktop mints a fresh 256-bit token (CSPRNG, hex-encoded) each time BridgeServer::start() runs and writes it, owner-only permissions, to BridgeAuth::tokenFilePath() — QStandardPaths::AppDataLocation for organization Dousen, application DousenDesktop:
- Windows:
%APPDATA%\Dousen\DousenDesktop\bridge.token - Linux:
~/.local/share/Dousen/DousenDesktop/bridge.token(or$XDG_DATA_HOME/…if set) - macOS:
~/Library/Application Support/Dousen/DousenDesktop/bridge.token
Every client — the Python addons (dousen/bridge.py) and the UE plugin (DousenBridgeAuth.h) — reconstructs this exact path independently rather than being told it. Tools launched by DousenDesktop can instead read the DOUSEN_BRIDGE_TOKEN environment variable, which takes precedence over the file. Read the token fresh on every request — it rotates whenever DousenDesktop restarts, and re-reading it means your tool survives that without a relaunch. If the file can't be written (e.g. a permissions problem), the relay still starts and fails closed: no client can authenticate until the file exists, because BridgeAuth::generateAndPersist() returns the in-memory token to the relay regardless of whether the write succeeded.
Every request is checked in this order (BridgeHttpRelay::authorizeRequest), before any route handler runs — this is the DOU-175 anti-CSRF hardening:
- An
Originheader present at all → 403. Legitimate clients (Qt's HTTP stack, UE'sFHttpModule, Python'surllib) never send one; only a browser does. This alone blocks in-browser requests and DNS-rebinding attacks outright — don't call the relay withfetch()/XHR. - A
Hostheader present and notlocalhost,127.0.0.1, or::1→ 403 (defense in depth against rebinding; IPv6 literals in brackets are left untouched rather than mis-parsed). - A missing or mismatched bearer token → 401, with a
WWW-Authenticate: Bearerheader. The compare is constant-time (BridgeAuth::tokensMatch) over the longer of the two byte lengths, so neither a length mismatch nor a partial match leaks through timing.
Only after all three pass does the relay look at the method and path — an authenticated request to an unknown route still gets a normal 404. Malformed requests (no space-delimited request line, fewer than two tokens on it) get 404 before authorization even runs.
The relay is not keep-alive: every response is sent with Connection: close and the socket is closed server-side (socket->disconnectFromHost()) immediately after, whether or not the client already closed its end. Open one connection per request — don't pipeline multiple requests down the same socket.
The BridgeEvent schema
Every event flowing through the relay — published, buffered, or polled back out — is the same eight-field shape, dousen::bridge::BridgeEvent in C++ (a plain value type; it used to be a protobuf message before the bridge went HTTP-only). The wire form returned by GET /bridge/events is this struct serialized as JSON, field-for-field:
| Field | Type | Meaning |
|---|---|---|
| event_type | string | What happened — mesh.export, dcc.open, etc. See the event-type catalog below. Required on publish; a missing/empty value is rejected with 400. |
| source_app | string | The registered id of the app that published the event ("blender", "unreal", …). Empty is stamped server-side — see the publish endpoint below for exactly what it becomes. |
| target_app | string | Empty means "everyone should look at this"; a non-empty value means only that app should act on it. The relay does not enforce this — it is a routing hint every client is expected to honor itself (see Poll semantics). |
| file_path | string | Absolute path to the file the event concerns, when there is one. Optional — task.context and some dcc.open variants leave it empty and carry their real payload in metadata_json instead. |
| metadata_json | string | A JSON string, not an object — decode it a second time to reach the metadata dict. Shape depends entirely on event_type; see the catalog below. |
| task_id | string | Non-empty only on task.context, where it is stamped by MainWindow::publishTaskContext. POST /bridge/publish does not accept this as a top-level field — publishers fold it into metadata_json instead (BridgeClient.publish(task_id=…) does this for you). |
| changelist_id | string | Same rule as task_id: only non-empty on internally-generated events (task.context); publishers fold it into metadata_json. |
| timestamp_sec | int64 | Unix seconds. Stamped by the relay at pushEvent() time if the incoming value is 0 — a client publishing over HTTP never sets this itself; the relay always assigns it. |
task_id and changelist_id are not accepted as top-level fields on POST /bridge/publish — the handler only reads event_type, source_app, target_app, file_path, and metadata_json from the request body. Events you publish over HTTP always come back from /bridge/events with the envelope's task_id/changelist_id empty, no matter what you put in the JSON body. Those two envelope fields are populated only on events DousenDesktop generates internally in C++ (currently just task.context, via BridgeServer::publish()/publishStored() called directly, bypassing the HTTP handler entirely). If your tool needs task/CL association round-tripped, embed it inside metadata_json — exactly what BridgeClient.publish(task_id=…, changelist_id=…) does below.
Endpoints
Quick reference; full request/response shapes are below.
| Method & path | Purpose |
|---|---|
| POST /bridge/register | Announce a running app / heartbeat. |
| DELETE /bridge/register | Withdraw on clean shutdown. |
| POST /bridge/publish | Push an event onto the bridge. |
| GET /bridge/events | Poll for events newer than a timestamp. |
| GET /bridge/dccs | List currently connected apps. |
| GET /bridge/status | Liveness probe (recently-publishing apps). |
POST /bridge/register
Announces a running app so it shows up in GET /bridge/dccs, the desktop's connected-apps badge, and Unreal's "Open in DCC" menu (Content Browser integration). Also doubles as the heartbeat that keeps it there — see the TTL note below.
JSON// Request body
{
"id": "batchtool", // required — no other field is read if this is absent/empty
"name": "Batch Exporter", // optional — falls back to id if empty/omitted
"command_port": 0 // optional int, default 0 — a live-editor command port
// (only meaningful for a DCC that accepts pushed
// asset-sync commands, e.g. the UE plugin)
}
JSON// Response — 200 always, even with an empty body or a missing id
{"ok": true}
A request with an empty or absent id is silently a no-op — the body is parsed but nothing is written to the registry — yet the relay still answers 200 {"ok":true}. The response does not reflect whether registration actually happened, so make sure id is set before you rely on appearing in /bridge/dccs. An empty request body skips parsing entirely and also returns 200 {"ok":true}.
Status codes: 200 (always, once auth passes). 401/403 as above — there is no 400 for a bad register body, it is simply ignored.
SHELLcurl -X POST http://127.0.0.1:8766/bridge/register \
-H "Authorization: Bearer $DOUSEN_BRIDGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"batchtool","name":"Batch Exporter"}'
DELETE /bridge/register
HTTPDELETE /bridge/register?id=batchtool
Removes id from the runtime registry immediately, ahead of its heartbeat expiring. Also unconditionally returns 200 {"ok":true}, whether or not that id was registered, and whether or not id was even supplied on the query string.
Status codes: 200 always, once auth passes. 401/403 as above.
SHELLcurl -X DELETE "http://127.0.0.1:8766/bridge/register?id=batchtool" \
-H "Authorization: Bearer $DOUSEN_BRIDGE_TOKEN"
POST /bridge/publish
Pushes one event onto the bridge's ring buffer, where every poller (including yourself, unless you filter it out) will see it on their next GET /bridge/events. Internally this goes through EventBroker::publish(), which also records source_app as "recently active" for 30 seconds (feeds GET /bridge/status) before fanning the event out to the ring buffer.
JSON// Request body
{
"event_type": "mesh.export", // required — empty/missing → 400
"source_app": "batchtool", // optional — empty is stamped as "http" server-side
"target_app": "", // optional — empty means "everyone"
"file_path": "D:/work/props/sword.fbx", // optional
"metadata_json": "{\"suggested_name\":\"S_Sword\"}" // optional — a JSON string, not an object
}
JSON// Response — 200 on success
{"ok": true}
Status codes: 200 on success; 400 if event_type is missing or empty. 401/403 as above.
task_id and changelist_id are not accepted as top-level fields on this endpoint — the relay's publish handler only reads event_type, source_app, target_app, file_path, and metadata_json. Events you publish here always come back from /bridge/events with task_id/changelist_id empty. Those top-level fields are only populated on events DousenDesktop generates internally (e.g. task.context). If your tool needs to carry task/CL association, embed it inside metadata_json instead — exactly what BridgeClient.publish() does below.
SHELLcurl -X POST http://127.0.0.1:8766/bridge/publish \
-H "Authorization: Bearer $DOUSEN_BRIDGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event_type": "mesh.export",
"source_app": "batchtool",
"file_path": "D:/work/props/sword.fbx",
"metadata_json": "{\"suggested_name\":\"S_Sword\",\"source_file\":\"D:/work/props/sword.ma\",\"task_id\":\"DOU-142\"}"
}'
GET /bridge/events
HTTPGET /bridge/events?since=1751450000
since is Unix seconds; the relay returns every stored event with a strictly greater timestamp_sec. Omit it (or pass 0) to get everything currently buffered.
JSON// Response — 200 always
{
"events": [
{
"timestamp_sec": 1751450000,
"event_type": "mesh.export",
"source_app": "maya",
"target_app": "",
"file_path": "D:/work/props/sword.fbx",
"metadata_json": "{\"suggested_name\":\"S_Sword\", ...}",
"task_id": "DOU-142",
"changelist_id": "31"
}
],
"timestamp": 1751450001
}
An empty target_app means "everyone"; a set one means only that app should act on it. metadata_json is a JSON string — decode it a second time to get the metadata object. The relay does no filtering of its own: every poller gets every buffered event past since, regardless of target_app or source_app — routing (target matching, event-type filtering, suppressing your own echo) is entirely the client's job. See Poll semantics below.
Status codes: 200 always (an empty events array is not an error). 401/403 as above.
SHELLcurl "http://127.0.0.1:8766/bridge/events?since=1751450000" \
-H "Authorization: Bearer $DOUSEN_BRIDGE_TOKEN"
GET /bridge/dccs
Returns a raw JSON array (not wrapped in an object) — the configured DCC list (from DousenDesktop's AppRegistry, always just {id, name}) merged with anything currently registered via POST /bridge/register and still inside its 90-second TTL. Expired runtime entries are pruned as a side effect of computing this response. This is what populates "Open in DCC" menus and the connected-apps badge.
JSON// Response — 200, a bare array
[
{"id": "maya", "name": "Maya"},
{"id": "batchtool", "name": "Batch Exporter", "command_port": 6768}
]
command_port is present only on a runtime (registered) entry that supplied a non-zero one — never on a configured DCC. It advertises a live-editor command port (currently meaningful only for the UE plugin's in-process command server) that lets DousenDesktop push an asset-sync command — a metadata-driven import, or a reimport when the asset already exists — straight into an already-open session instead of spawning a headless one.
Status codes: 200 always. 401/403 as above.
SHELLcurl http://127.0.0.1:8766/bridge/dccs \
-H "Authorization: Bearer $DOUSEN_BRIDGE_TOKEN"
GET /bridge/status
JSON// Response — 200
{
"apps": [
{"app_id": "maya", "subscription_count": 0}
]
}
This is a different tracking mechanism from /bridge/dccs: apps here is EventBroker::connectedApps() — whoever has published an event in the last 30 seconds, not whoever is registered. An app that calls /bridge/register but never publishes will show up in /bridge/dccs but not here. subscription_count is always 0 in the current implementation (counts.emplace(app, 0)) — there's no real per-app subscriber count behind it. Treat this endpoint purely as "has this app said anything recently", not as a subscription tally.
Status codes: 200 always. 401/403 as above.
SHELLcurl http://127.0.0.1:8766/bridge/status \
-H "Authorization: Bearer $DOUSEN_BRIDGE_TOKEN"
The event-type catalog
The event types below are everything currently published anywhere in the codebase — the DCC addons (python/dousen/addons/), DousenDesktop itself, and the Unreal plugin. Nothing in this table is enforced by the relay; event_type is an opaque string to it. The consuming side (FAssetSyncManager in Unreal, or your own tool) decides what each one means.
Don't confuse event types with validator rule ids — mesh.poly_count, texture.max_resolution and friends are validation rules, never bridge events.
Export events — published by DCC addons
These carry the metadata shape FAssetSyncManager (Unreal) consumes for import; the Python-side contract lives in dousen/addons/_ue_contract.py, built by every addon's publish_export() so the shape can't drift per call site.
| Event | Required metadata | Optional metadata | Meaning |
|---|---|---|---|
| mesh.export | suggested_name, source_file | suggested_ue_path, asset_type, convention_tags, cv_tags, references, material_assignment | A published static or skeletal mesh (a skeletal FBX also yields the skeleton). cv_tags commonly arrives in a second, later mesh.export for the same file — DousenDesktop's local CV worker (DOU-221) re-publishes the original event enriched with cv_tags once background tagging/review finishes (DOU-223), so a live UE session picks them up without a re-export. |
| texture.export | suggested_name (the target mesh), textures (list of absolute paths) | suggested_ue_path, master_material, material_assignment | A published texture set. |
| animation.export | suggested_name, source_file | suggested_ue_path, skeleton, asset_type, convention_tags, cv_tags, references | A published animation clip. When skeleton is absent, the plugin auto-resolves it from references (the rig scene the animation references → its imported SkeletalMesh → its skeleton), falling back to the *_Skeleton asset in the target folder. A legacy anim.export alias is still recognized by the desktop's internal export-notification hook (BridgeServer.cpp), but every current publisher emits animation.export — use that. |
| scene.export | — | — | An auto-publish triggered by saving the scene (Blender/Substance addons). Carries no metadata beyond the envelope's file_path; task/CL context, if any, still rides in via metadata_json the same as any other publish. |
material_assignment (accepted on both mesh.export and texture.export) is a catalog-driven alternative to the flat textures/master_material fields — one assignment per mesh material slot, each with a material_id and/or material_path, per-slot textures, and scalar_params/vector_params. See the material catalog integration page for the full shape; allow_nanite is deliberately never read from this block — UE always resolves it from its own catalog Data Asset. Unknown extra keys on any export event (bundle_id and similar) pass through untouched.
Asset-save events — published by Unreal
DOU-128 made the UE plugin a first-class bridge participant, not just a listener: FDousenUEModule::OnPackageSaved fires on every editor asset save and republishes it to the bridge with source_app: "unreal". These reuse two of the names above plus two new ones, but with a completely different metadata shape than the DCC-side export events — this is not the same contract as the table above.
| Event | Triggered by saving | Metadata |
|---|---|---|
| mesh.export | A UStaticMesh or USkeletalMesh package | asset_path (the UE package path), asset_class (e.g. StaticMesh), package_file (the on-disk .uasset path) |
| texture.export | A UTexture2D package | |
| material.export | A UMaterial or UMaterialInstance package | |
| asset.export | An ALandscapeProxy package |
A consumer that expects the DCC-side suggested_name/source_file shape will find neither field present on a UE-originated mesh.export or texture.export — switch on source_app == "unreal" (or just check which keys are present) before assuming which contract you're looking at.
Routing & control events
| Event | Published by | Metadata | Effect |
|---|---|---|---|
| dcc.open | Unreal's Content Browser "Open in DCC" menu (FDousenContentBrowserExtension). target_app is left empty. |
jira_key, asset_name, preferred_dcc |
DousenDesktop's SourceAssetTracker handles every dcc.open event unconditionally (it isn't gated on target_app): it fetches the source-asset record from DousenCore, resolves the local path (local disk, Perforce via p4 where, Google Drive, or OneDrive mount), and launches preferred_dcc through AppRegistry — spawning it if it isn't already running. |
A custom tool addressing an already-running Houdini, Substance Painter, or Substance Designer session (target_app == that session's own registered id) |
file_path |
That addon's own bridge subscription (_handle_dcc_open) loads the file directly into the running session — no relaunch. Blender/Maya/3ds Max don't implement this hook; DousenDesktop's launch-a-fresh-process path above covers them instead. |
|
Any client, targeting Unreal (target_app == "unreal", or empty) |
asset_path (falls back to the envelope's file_path if absent) |
FDousenUEModule::OnBridgeEventReceived looks the asset up in the Asset Registry and syncs the Content Browser to it. No built-in DCC addon currently publishes this direction — a custom tool could use it to jump an open Unreal Editor straight to an asset. |
|
| task.context | DousenDesktop's MainWindow, whenever the artist changes the selected task in the task panel (TaskListWidget::taskContextChanged). source_app is "dousen_desktop". |
task_id, changelist_id, task_title — duplicated as the envelope's top-level task_id/changelist_id fields and repeated inside metadata_json |
Lets any subscriber tag its own output with the artist's active task without re-fetching from Jira. The Python addons cache this via _p4_ops.update_task_context() so it survives independent of environment variables set at DCC launch. |
Poll semantics
- The Python addons poll every 0.5 seconds (
_POLL_INTERVALinbridge.py); the UE plugin'sFBridgePollerpolls every 2 seconds by default. Anything up to a few seconds is fine for a background tool. - Timestamps are whole seconds, and the relay returns events with
timestamp_secstrictly greater thansince. To avoid dropping two events published in the same second,BridgeClient(the Python reference client) keepssinceone second behind its high-water mark and de-duplicates the re-fetched boundary events by identity (timestamp,event_type,source_app,file_path,event_id). The UE poller does not do this — it advances its cursor straight to the server's returnedtimestampeach cycle — so a same-second straggler is a narrower (if rare) risk on that path. - The relay keeps the most recent 200 events (
MAX_EVENTS) in memory; there is no persistence and no replay beyond that. A client that's been offline longer than it takes to fill the buffer will silently miss the oldest of what it lost. /bridge/registerentries (what backs/bridge/dccsandcommandPortFor()) expire after 90 seconds without a heartbeat (RUNTIME_DCC_TTL_SECS). The built-in heartbeat cadences — 10s from the Python addons (_REREGISTER_SECS), 30s from the UE plugin (HEARTBEAT_SECS) — both sit comfortably inside that window; re-register at least that often to stay on the connected list and in "Open in DCC" menus.- The relay performs no addressing or de-duplication itself:
GET /bridge/eventsalways returns the full buffer pastsinceto every caller. Each client must apply its owntarget_appmatch and suppress its own echo (skip anything whosesource_appequals your registered id) — see_should_deliverinbridge.pyfor the reference logic.
Writing a custom bridge client
Every integration follows the same three-call pattern: register once (and re-heartbeat), poll /bridge/events in a loop, publish when you have something to say. The minimal HTTP sequence, with no client library at all:
SHELL# 1. Announce yourself
curl -s -X POST http://127.0.0.1:8766/bridge/register \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"id":"batchtool","name":"Batch Exporter"}'
# 2. Poll in a loop (re-send #1 every ~10s alongside this)
curl -s "http://127.0.0.1:8766/bridge/events?since=$LAST" \
-H "Authorization: Bearer $TOK"
# 3. Publish when you have an export
curl -s -X POST http://127.0.0.1:8766/bridge/publish \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"event_type":"mesh.export","source_app":"batchtool",
"file_path":"/work/props/sword.fbx",
"metadata_json":"{\"suggested_name\":\"S_Sword\",\"source_file\":\"/work/props/sword.ma\"}"}'
# 4. Withdraw on shutdown
curl -s -X DELETE "http://127.0.0.1:8766/bridge/register?id=batchtool" \
-H "Authorization: Bearer $TOK"
The Python package that ships with DousenDesktop (dousen/bridge.py) implements exactly this pattern as a reusable client — reading the token, re-registering on a timer, running the poll loop on a background thread with reconnect/backoff, and de-duplicating the same-second boundary. Use it as the reference implementation, whether or not you actually run it:
PYTHONfrom dousen.bridge import BridgeClient
bridge = BridgeClient() # http://127.0.0.1:8766, token auto-read
bridge.register("batchtool", "Batch Exporter")
def on_event(event):
print(event.event_type, event.file_path, event.metadata)
# poll in a background thread; auto-reconnects with exponential backoff
bridge.subscribe_async("batchtool", event_type_filter="mesh.*", on_event=on_event)
bridge.publish(
event_type="mesh.export",
source_app="batchtool",
file_path="D:/work/props/sword.fbx",
metadata={"suggested_name": "S_Sword", "source_file": "D:/work/props/sword.ma"},
task_id="DOU-142", # BridgeClient folds this into metadata_json for you
)
bridge.close() # stops the poll thread and calls DELETE /bridge/register
Event-type filters accept an exact name (mesh.export), a prefix (mesh.*), or empty for everything — BridgeClient applies the filter and the echo/target-app suppression client-side, matching the rules in Poll semantics. HttpBridgeClient is kept as a backwards-compatible alias for BridgeClient from the pre-HTTP-only days; new code should just import BridgeClient. Once registered and heartbeating, your tool appears in /bridge/dccs and on the desktop's connected-apps badge.
If your tool is a persistent, always-running session (like the Houdini and Substance addons), have it accept dcc.open events addressed to it (target_app == "batchtool", metadata {"file_path": …}) and load the file in place — see the dcc.open row above for the exact pattern.
That pattern is not what powers Unreal's Content Browser "Open in DCC" menu, though. That flow never sets target_app — it goes through metadata's preferred_dcc field to DousenDesktop's SourceAssetTracker, which only launches apps from its own configured AppRegistry list (m_registry.apps()), not arbitrary tools that have only called POST /bridge/register. For your tool to show up as a launch target in that menu, it needs an entry in DousenDesktop's app-launcher configuration — see Launching DCCs — registering over the bridge alone only gets you into /bridge/dccs and the connected-apps badge, not the launcher list.