Plugins
UGENT channel plugins run as separate OS processes communicating via JSON-RPC over Unix domain sockets. Each channel is isolated — crashes don't cascade.
Plugin Setup
Interactive (REPL)
/plugin setupThe wizard lets you select which plugins to enable, configure swarm nodes, and writes instance-scoped config.
Auto (TUI)
/plugin setupNon-interactive — generates instance-scoped config from global settings.
Migrate
/plugin migrateRegenerates instance-scoped config from global config.
Plugin Registration
Plugins are registered in plugins.toml:
schema_version = 1
[[plugins]]
id = "telegram-main"
kind = "channel-telegram"
enabled = true
command = "./target/release/channel-telegram-worker"
args = ["--mode", "polling"]
env_refs = ["TELEGRAM_BOT_TOKEN"]
[plugins.limits]
max_in_flight = 64
max_retries = 5
heartbeat_secs = 30
[plugins.routing]
workspace = "/path/to/workspace"
allowed_senders = ["telegram:123456789"]Building Plugins
Use the public plugin template:
cargo install cargo-generate
cargo generate --git https://github.com/UGENTAI/ugent-plugin-templateThe template includes:
- Full lifecycle: heartbeat, inbound listener, outbound polling, graceful shutdown
- Crash-recovery outbox for in-flight messages
- Integration test with MockBridge
- CI workflow (fmt + clippy + test)
What You Implement
- Channel API connection — connect to your channel (WebSocket, HTTP, webhook)
- Inbound normalization — map channel messages to UGENT's inbound format
- Outbound delivery — send responses back to the channel API
- Media handling — download/upload attachments (optional)
Process Supervision
The plugin supervisor manages:
- Kill on drop — dropped handles don't leak processes
- Process groups — group-targeted termination (Unix)
- Parent-death signaling — reduces orphan risk (Linux)
- Two-phase shutdown — graceful terminate, then force-kill
- Circuit breaker — 3 consecutive cooldown rounds auto-disable a plugin
- Stale worker cleanup — reaps workers from previous crashed sessions
13 Channels
| Channel | Kind | Notes |
|---|---|---|
| Telegram | channel-telegram | Polling or webhook mode |
| Slack | channel-slack | Socket Mode or webhook |
| Discord | channel-discord | Gateway WebSocket |
| LINE | channel-line | Messaging API + webhook |
channel-wechat | Personal account bridge | |
| WeCom | channel-wecom | Enterprise WeChat |
| Weixin | channel-weixin | WeChat Official Account |
channel-email | Gmail push or IMAP | |
| DingTalk | channel-dingtalk | Robot webhook |
channel-whatsapp | Cloud API or proxy | |
| Salesforce | channel-salesforce | Platform Events |
| Web | channel-web | HTTP API, SSE streaming, voice and live calls |
| Translation | channel-translate | Document/text translation pipeline |
Web Channel API
The web channel serves an OpenAI-compatible API plus native UGENT streaming, voice and live calls. It listens on 127.0.0.1:8787 unless UGENT_WEB_BIND and UGENT_WEB_PORT say otherwise, and every request carries its key as x-api-key or Authorization: Bearer.
| Endpoint | Purpose |
|---|---|
POST /v1/chat/completions | OpenAI-compatible chat (stream + non-stream), with the UGENT extensions |
GET /v1/models | The models a caller may name |
POST /v1/files | Upload a file into a conversation |
GET /v1/files/{id} | Download a file, with byte ranges |
GET /v1/ugent/events | SSE event stream, resumable by cursor |
GET /v1/ugent/activity | SSE activity feed for one conversation: tools, questions, compaction |
POST /v1/ugent/commands | Slash commands a channel may run: /model, /models, /recall, /steering, /btw, /harness, /refine and /sandbox (changes only with allow_remote_control); anything else is answered as unsupported |
POST /v1/ugent/steering | Steer a running turn |
POST /v1/ugent/stop | Stop a conversation's running turn |
GET /v1/ugent/receipt | Where one message stands |
GET /v1/ugent/conversation/snapshot | Where a conversation's turns stand, for a client recovering its state |
GET /v1/ugent/snapshot | The tail of one session's messages, for its owner |
GET /v1/ugent/history | A session's messages that compaction hid, paged |
POST /v1/ugent/transcribe | Dictation: a recording turned into text, with no turn |
POST /v1/ugent/speech | Read aloud: one segment of a finished answer, spoken |
GET /v1/ugent/live | Live call (WebSocket) |
GET /v1/ugent/status | Host status, including what voice can do |
GET /v1/health | Worker health |
GET /v1/ugent/status, GET /v1/ugent/sandbox, PUT /v1/ugent/directory and POST /v1/ugent/conversations/repair answer operator keys only. The voice socket GET /v1/ws/voice is gone: send a voice message as a chat turn, or hold a live call (Voice).
Keys and callers
Each key belongs to a caller, and its role decides whom it may speak for:
operator— a trusted front end that signs its own users in, such as the web workspace. It may name any end user, and a request that names nobody acts as the operator.application— an integrating application. It may name only federated users of the issuers it lists (federation_issuerwithfederation_subject), and a request that names nobody acts as the caller's own anonymous user,web-user@<name>.
UGENT_WEB_API_KEY is an operator key unless api_key_role says otherwise. Further callers go in ugent.toml, each key read from the environment variable it names — which the plugin's secret_env_refs can fill from the vault, so no key sits in a config file:
[web.auth]
api_key_role = "operator" # the role of UGENT_WEB_API_KEY
[[web.auth.keys]]
name = "crm"
key_env = "UGENT_WEB_API_KEY_CRM"
role = "application" # the default for an added key
issuers = ["example-crm"]A conversation belongs to whoever first sends a message in it, uploads a file to it or starts a call on it. A turn, a question reply or a file on someone else's conversation is refused — 403, or an error in the stream — and runs nothing; an event subscription sees only what its user may; a stop needs the turn's own user, the operator, or the exact turn_id it stops. Who a Channel Caller Acts For has the full rules.
A request carrying a JWT from an issuer listed in [[web.auth.issuers]] (issuer, jwks_url, audience, actor_prefix) is verified, and its subject overrides any identity in the body; require_verified_identity = true refuses a request that names a user without such a token. See also Federated Actors for External Applications.
Chat request extensions
UGENT keeps the conversation server-side, so only the last user entry of messages is read. Beside the OpenAI fields, a request may carry:
| Field | Purpose |
|---|---|
conversation_id | The conversation; its first message creates the session |
client_message_id | Idempotency key: a repeat is answered from the message's receipt and runs nothing |
client_id | Namespaces conversation ids, for several clients sharing one channel |
turn_id | The turn's id, so another client can steer or stop exactly this turn |
user, federation_issuer, federation_subject | The end user (Keys and callers) |
include_thinking, echo_user_message | Stream reasoning; echo the user's message |
ugent_allowed_tools, ugent_denied_tools | Narrow this turn's tools; ugent_denied_tools: ["*"] runs it with none |
ugent_include_tool_events | Stream tool executions as ugent_segment: "tool" |
ugent_citation_ids | Evidence ids the request supplied in its own prompt, reported back when the answer cites them |
ugent_disable_memory_recall | Recall no workspace memory into this turn |
ugent_disable_code_context | Skip the pre-LLM code-context fetch |
ugent_disable_memory_write | Keep this turn out of memory: no extraction, no compaction summary, no memory_control tool |
ugent_input_mode | "voice" for a voice message, "live" for a turn a call hands over; any other value is a 400 |
ugent_interaction_reply | Answers a waiting question by its id (Questions) |
A request's model is the turn's own. model runs that one turn and stores nothing, and a model the operator does not allow is a 400. ugent-default, or no model, gives the user's stored /model choice, then the caller's default, then the global default; only the /model command stores a choice. Where two instances offer the same model name, GET /v1/models also lists <provider>:<instance>@<model> for each — the only way to pick one copy. See Per-User Model Selection.
The opt-outs only narrow. Nothing a request sends widens what routing allows, and ugent_allowed_tools: [] is refused rather than read as "no restriction". They follow the turn into the work it delegates — every task it creates carries them — and a turn restricted this way is not offered scheduled jobs, which would run without them. An operator can apply the same opt-outs, and a model, to every turn that asserts one federation issuer; a request can add to them but not switch them back on. See Per-Caller Defaults.
What the chat stream sends
A streaming reply is OpenAI chunks ending in data: [DONE], each delta tagged with ugent_segment: answer, thinking, tool, error, or answer_reset, which withdraws every answer chunk since the last reset. The rest comes as named events, so a client that reads only message events is unaffected:
| Event | Carries |
|---|---|
ugent.user_message | Once per turn, before the answer: the message as the conversation keeps it, and each recording with what was heard (transcript, duration_ms) |
ugent.citations | After the answer: the verified citation ids it used |
ugent.artifacts | The files the turn returned |
ugent.artifacts_pending | Files still on their way: read them from the turn's receipt |
ugent.receipt | Ahead of a refusal: the message's receipt, naming the turn that runs |
The x-ugent-turn-id response header names the turn. A request the agent refuses ends the stream at once with an error chunk, and so does a turn that fails after it started, after whatever had already streamed. On /v1/ugent/events that ending is turn.failed, addressed to the sender alone; an answered turn's turn.completed names it the same way.
A repeat of a client_message_id runs nothing: it is refused as DuplicateInFlight or DuplicateProcessed (409 without streaming), under the first copy's turn. The same id with different content, or a repeat of a turn that failed or was interrupted after it started — and so may already have acted — is a 409 with the reason: send it as a new message.
Resuming and recovering
- Events resume by cursor. Each event's SSE
id:on/v1/ugent/eventsand/v1/ugent/activityis a durable cursor. Send the last one back asLast-Event-ID, or acursorquery parameter, and the stream goes on after it, each event once and in order. A position the log no longer holds is answered with oneugent.gapevent —reasonispruned,other_storeorunknown— carrying the conversation's snapshot where it can be read for the subscriber; take the snapshot up in place of the events that are gone. GET /v1/ugent/conversation/snapshotsays where a conversation stands, for a client that lost its own record: its 50 newest receipts, each only its sender's, a completed one with its answer, citations and files; the question waiting, for the user it asked;as_of, the daemon's clock, to measure that question's deadline against; the eventcursorit was read at; and each goal's and task's latest committed verification.GET /v1/ugent/receiptlooks one message up byconversation_idandclient_message_id: its turn, and itsstate—accepted,running,completed,cancelled,refused,failed,interruptedorabandoned. Only its sender and the operator see it; to anyone else it is a404, as a missing message is.- File links last. A
/v1/files/<id>link opens only for its conversation's owner, and keeps working across a channel restart for as long as retention keeps the bytes (a day by default,UGENT_PLUGIN_MEDIA_TTL_SECS). Downloads answerRangewith206, which Safari needs to play audio, and with416for a range that starts past the end.
Questions
A question the agent asks mid-turn arrives as interaction.requested on the event and activity streams. Its timeout_at is the deadline; measure it against the newest event's ts, not the client's own clock. Answer it with a chat request whose ugent_interaction_reply names it:
{
"conversation_id": "conv-1",
"messages": [{ "role": "user", "content": "Priority: Low" }],
"ugent_interaction_reply": {
"request_id": "<the question's id>",
"action": "submit",
"answers": [{ "question_id": "priority", "option_id": "low" }]
}
}value answers a text, secret or confirm question, and "action": "abort" dismisses it. A reply is applied only to the question it names, only while that question waits, and only when it fits; of two replies to one question, one is applied. Anything else is refused with how the question stands — This question was already answered., This question has expired., This question was cancelled: its turn stopped. and the like — and never runs as a turn. The message content is what a transcript shows and is not parsed, so a secret belongs in value alone.
Voice
GET /v1/ugent/status, read with an operator key, says what voice can do before a client offers any of it: status.media.voice.input for voice messages and dictation, output for read aloud, and live for calls. Each has available, and a reason — disabled, classification or no_engine — when it is false. Offer a microphone for a model only when input.available holds and either input.transcription is true or input.native_models lists that model, and dictation when input.dictation is true. What the Daemon Reports has the whole report, and Voice how to set it up.
| What | How |
|---|---|
| Voice message | Upload the recording — 16 kHz mono 16-bit PCM WAV — to POST /v1/files in its conversation, then send it as a file part of a chat turn with ugent_input_mode: "voice". The daemon transcribes it before routing, ugent.user_message says what was heard, and the receipt keeps it (input_transcript) for a page that closed first. A voice message nothing can use is answered with why, without a model call. |
| Dictation | POST /v1/ugent/transcribe, multipart: file (the same WAV), conversation_id, optional language. Answers {text, language, duration_ms} and keeps nothing. |
| Read aloud | POST /v1/ugent/speech with {conversation_id, turn_id, segment} names a finished turn, never words. Answers {url, mime, segment, segment_count, text, duration_ms}: play url, and ask for segment i + 1 while i plays. Another person's turn, an unfinished one and an unknown one all answer 404. |
| Live call | GET /v1/ugent/live, a WebSocket that names its conversation and user in the query string, as an upload does. |
A live call speaks protocol v1. The client sends JSON start first, then microphone audio as binary frames — PCM16, mono, 24 kHz, as the server's are — and text, input_end (push-to-talk), interrupt, playback (how much of a reply was heard) and end as needed. The server sends ready, state, speech_started (stop playing at once), audio (naming the reply the binary frames that follow belong to), captions as user_transcript and assistant_transcript, and finally ended with its reason. A tool_call is the voice model handing work to the agent: run its request as a chat turn of the conversation with ugent_input_mode: "live", then send that turn's final answer back as tool_result, one request at a time. The channel holds the provider connection, so the client never sees a provider key. Close 4403 means the conversation is not the caller's, 4409 that the caller already has a call open, and 4422 that calls are unavailable here.