Skip to content

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 setup

The wizard lets you select which plugins to enable, configure swarm nodes, and writes instance-scoped config.

Auto (TUI) ​

/plugin setup

Non-interactive — generates instance-scoped config from global settings.

Migrate ​

/plugin migrate

Regenerates instance-scoped config from global config.

Plugin Registration ​

Plugins are registered in plugins.toml:

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:

bash
cargo install cargo-generate
cargo generate --git https://github.com/UGENTAI/ugent-plugin-template

The 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 ​

  1. Channel API connection — connect to your channel (WebSocket, HTTP, webhook)
  2. Inbound normalization — map channel messages to UGENT's inbound format
  3. Outbound delivery — send responses back to the channel API
  4. 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 ​

ChannelKindNotes
Telegramchannel-telegramPolling or webhook mode
Slackchannel-slackSocket Mode or webhook
Discordchannel-discordGateway WebSocket
LINEchannel-lineMessaging API + webhook
WeChatchannel-wechatPersonal account bridge
WeComchannel-wecomEnterprise WeChat
Weixinchannel-weixinWeChat Official Account
Emailchannel-emailGmail push or IMAP
DingTalkchannel-dingtalkRobot webhook
WhatsAppchannel-whatsappCloud API or proxy
Salesforcechannel-salesforcePlatform Events
Webchannel-webHTTP API, SSE streaming, voice and live calls
Translationchannel-translateDocument/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.

EndpointPurpose
POST /v1/chat/completionsOpenAI-compatible chat (stream + non-stream), with the UGENT extensions
GET /v1/modelsThe models a caller may name
POST /v1/filesUpload a file into a conversation
GET /v1/files/{id}Download a file, with byte ranges
GET /v1/ugent/eventsSSE event stream, resumable by cursor
GET /v1/ugent/activitySSE activity feed for one conversation: tools, questions, compaction
POST /v1/ugent/commandsSlash 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/steeringSteer a running turn
POST /v1/ugent/stopStop a conversation's running turn
GET /v1/ugent/receiptWhere one message stands
GET /v1/ugent/conversation/snapshotWhere a conversation's turns stand, for a client recovering its state
GET /v1/ugent/snapshotThe tail of one session's messages, for its owner
GET /v1/ugent/historyA session's messages that compaction hid, paged
POST /v1/ugent/transcribeDictation: a recording turned into text, with no turn
POST /v1/ugent/speechRead aloud: one segment of a finished answer, spoken
GET /v1/ugent/liveLive call (WebSocket)
GET /v1/ugent/statusHost status, including what voice can do
GET /v1/healthWorker 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_issuer with federation_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:

toml
[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:

FieldPurpose
conversation_idThe conversation; its first message creates the session
client_message_idIdempotency key: a repeat is answered from the message's receipt and runs nothing
client_idNamespaces conversation ids, for several clients sharing one channel
turn_idThe turn's id, so another client can steer or stop exactly this turn
user, federation_issuer, federation_subjectThe end user (Keys and callers)
include_thinking, echo_user_messageStream reasoning; echo the user's message
ugent_allowed_tools, ugent_denied_toolsNarrow this turn's tools; ugent_denied_tools: ["*"] runs it with none
ugent_include_tool_eventsStream tool executions as ugent_segment: "tool"
ugent_citation_idsEvidence ids the request supplied in its own prompt, reported back when the answer cites them
ugent_disable_memory_recallRecall no workspace memory into this turn
ugent_disable_code_contextSkip the pre-LLM code-context fetch
ugent_disable_memory_writeKeep 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_replyAnswers 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:

EventCarries
ugent.user_messageOnce per turn, before the answer: the message as the conversation keeps it, and each recording with what was heard (transcript, duration_ms)
ugent.citationsAfter the answer: the verified citation ids it used
ugent.artifactsThe files the turn returned
ugent.artifacts_pendingFiles still on their way: read them from the turn's receipt
ugent.receiptAhead 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/events and /v1/ugent/activity is a durable cursor. Send the last one back as Last-Event-ID, or a cursor query parameter, and the stream goes on after it, each event once and in order. A position the log no longer holds is answered with one ugent.gap event — reason is pruned, other_store or unknown — 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/snapshot says 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 event cursor it was read at; and each goal's and task's latest committed verification.
  • GET /v1/ugent/receipt looks one message up by conversation_id and client_message_id: its turn, and its state — accepted, running, completed, cancelled, refused, failed, interrupted or abandoned. Only its sender and the operator see it; to anyone else it is a 404, 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 answer Range with 206, which Safari needs to play audio, and with 416 for 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:

json
{
  "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.

WhatHow
Voice messageUpload 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.
DictationPOST /v1/ugent/transcribe, multipart: file (the same WAV), conversation_id, optional language. Answers {text, language, duration_ms} and keeps nothing.
Read aloudPOST /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 callGET /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.

Released under the Private Beta License.