Agentgram API is a Go multiplexer that connects to multiple remote AI agents and MCP servers. It handles authentication, permissions, session management, and protocol translation, exposing a unified SSE interface using the AG-UI protocol.
- Base URL:
http(s)://<host>:8080 - Content-Type:
application/jsonfor request/response bodies;text/event-streamfor SSE streaming endpoints - Authentication: OIDC (Keycloak) with session cookies, JWT bearer tokens, and optional GitHub OAuth for agent token forwarding
- Frontend redirects to
GET /auth/login - User authenticates with Keycloak
- Keycloak redirects to
GET /auth/callbackwith authorization code - API exchanges code for tokens, creates a server-side session in Redis, and sets an
auth_sessionHttpOnly cookie - Subsequent API requests are authenticated via the session cookie or JWT
Authorization: Bearer <token>header
- Name:
auth_session - Attributes:
HttpOnly,SameSite=Lax,Path=/ - Max-Age: Configurable (default 86400 seconds)
- Secure: Configurable via
auth.cookie_secure
The JWT is issued by Keycloak and validated using JWKS. Claims include:
email- user emailgroups- list of Google Workspace groupssub- subject identifier
Users can link their GitHub account to forward tokens to agents that require GitHub access (e.g., coding agents). The GitHub token is stored in the auth session alongside the OIDC tokens.
All error responses use the following JSON structure:
{
"error": "description of what went wrong"
}| Status | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 204 | No Content (successful deletion) |
| 400 | Bad Request (invalid input) |
| 401 | Unauthorized (missing or invalid auth) |
| 403 | Forbidden (insufficient permissions) |
| 404 | Not Found |
| 500 | Internal Server Error |
| 503 | Service Unavailable |
All chat endpoints (/api/agents/{id}/chat, /api/groups/{id}/chat, /api/mcp/servers/{id}/chat, /api/mcp/chat) respond with text/event-stream using the AG-UI protocol.
| Event Type | Description |
|---|---|
RUN_STARTED |
Signals the beginning of a run |
RUN_FINISHED |
Signals the end of a run |
RUN_ERROR |
Signals an error during the run |
TEXT_MESSAGE_START |
Start of a text message from an assistant |
TEXT_MESSAGE_CONTENT |
A chunk of streaming text content |
TEXT_MESSAGE_END |
End of a text message |
TOOL_CALL_START |
Start of a tool call |
TOOL_CALL_ARGS |
Arguments for a tool call (streamed) |
TOOL_CALL_END |
End of a tool call with result |
CUSTOM |
Custom event (e.g., CONVERSATION_STEP) |
data: {"type":"RUN_STARTED","threadId":"<session-id>","runId":"<uuid>"}
data: {"type":"TEXT_MESSAGE_START","messageId":"<uuid>","role":"assistant","agentId":"<optional>","isThinking":false}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"<uuid>","delta":"Hello","agentId":"<optional>"}
data: {"type":"TEXT_MESSAGE_END","messageId":"<uuid>","agentId":"<optional>"}
data: {"type":"TOOL_CALL_START","toolCallId":"<id>","toolName":"search","serverId":"<optional>"}
data: {"type":"TOOL_CALL_ARGS","toolCallId":"<id>","delta":"{\"query\":\"test\"}"}
data: {"type":"TOOL_CALL_END","toolCallId":"<id>","result":"..."}
data: {"type":"RUN_ERROR","message":"error description","code":"<optional>"}
data: {"type":"RUN_FINISHED","threadId":"<session-id>","runId":"<uuid>"}
data: {"type":"RUN_STARTED","threadId":"sess-123","runId":"run-456"}
data: {"type":"TEXT_MESSAGE_START","messageId":"msg-789","role":"assistant"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-789","delta":"Hello"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg-789","delta":", how can I help?"}
data: {"type":"TEXT_MESSAGE_END","messageId":"msg-789"}
data: {"type":"RUN_FINISHED","threadId":"sess-123","runId":"run-456"}
In group debates, events include an agentId field to identify which agent is responding. The isThinking field in TEXT_MESSAGE_START marks intermediate thinking steps.
data: {"type":"CUSTOM","subType":"CONVERSATION_STEP","data":{"agentId":"agent-1","stepIndex":0,"totalSteps":3,"isUserTurn":false}}
Access to agents and MCP servers is controlled by two lists:
allowed_groups: Google Workspace groups (matched against JWTgroupsclaim)allowed_users: Individual email addresses
- If both lists are empty, the resource is accessible to all authenticated users
- The wildcard
*in either list grants access to all authenticated users - A user needs to match at least one group OR one user entry to gain access
- Admin users (role
adminin the database) can access admin API endpoints regardless of group membership
No authentication required.
Liveness probe.
Response 200:
{
"status": "ok"
}Readiness probe. Returns ready if at least one agent is healthy or all agents have unknown status (no health check configured).
Response 200:
{
"status": "ready",
"details": {
"total_agents": 3,
"healthy_agents": 2
}
}Response 503:
{
"status": "degraded",
"details": {
"total_agents": 3,
"healthy_agents": 0,
"unhealthy_agents": ["agent-1", "agent-2", "agent-3"]
}
}Returns public configuration (feature flags and available LLM models). No secrets are exposed.
Response 200:
{
"features": {
"summarizer_enabled": true
},
"available_models": [
{
"id": "gpt-4o",
"name": "GPT-4o",
"provider": "openai",
"default": true
}
]
}Redirects the user to Keycloak for OIDC authentication.
Response: 302 Found redirect to Keycloak authorization URL.
Handles the OIDC callback from Keycloak. Exchanges the authorization code for tokens, creates a session, sets the auth_session cookie, and redirects to /.
Query Parameters:
code(required) - Authorization codestate(required) - CSRF state token
Response: 302 Found redirect to /.
Errors: 400 (missing code/state, invalid state), 401 (authentication failed), 500 (token exchange failed).
Clears the auth session and returns the Keycloak logout URL for SSO logout.
Response 200:
{
"ok": true,
"logout_url": "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/logout?..."
}Returns the current user's session info based on the auth_session cookie.
Response 200 (authenticated):
{
"authenticated": true,
"email": "user@example.com",
"groups": ["group-a", "group-b"],
"github_connected": true,
"github_username": "octocat"
}Response 200 (not authenticated):
{
"authenticated": false
}Redirects the user to GitHub for OAuth authorization. Requires an active OIDC session.
Response: 302 Found redirect to GitHub authorization URL.
Errors: 404 (GitHub OAuth not configured), 500 (internal error).
Handles the GitHub OAuth callback. Stores the GitHub token in the user's existing auth session.
Query Parameters:
code(required) - Authorization codestate(required) - CSRF state token
Response: 302 Found redirect to /.
Returns the current GitHub connection status.
Response 200:
{
"connected": true,
"username": "octocat"
}Removes the GitHub token from the user's session.
Response 200:
{
"ok": true
}Authentication required.
Returns the current user's profile information.
Response 200:
{
"email": "user@example.com",
"groups": ["group-a", "group-b"],
"is_admin": false
}Authentication required. Agents are filtered by user permissions.
Lists all agents the authenticated user has access to.
Response 200:
{
"agents": [
{
"id": "my-agent",
"name": "My Agent",
"description": "A helpful agent",
"category": "general",
"protocol": "rest",
"status": "healthy",
"require_github_token": false
}
]
}Returns a single agent by ID.
Response 200:
{
"id": "my-agent",
"name": "My Agent",
"description": "A helpful agent",
"category": "general",
"protocol": "rest",
"status": "healthy",
"require_github_token": false
}Errors: 400 (missing ID), 403 (access denied), 404 (not found).
Returns the health status of a specific agent.
Response 200:
{
"status": "healthy",
"details": {
"agent_id": "my-agent",
"name": "My Agent",
"protocol": "rest"
}
}Errors: 400 (missing ID), 403 (access denied), 404 (not found).
Authentication required. Agent-level permission check.
Sends a message to an agent and streams the response as AG-UI SSE events. If no session_id is provided, a new session is created automatically.
Request Body:
{
"messages": [
{
"role": "user",
"content": "Hello, how are you?",
"attachments": [
{
"filename": "image.png",
"content_type": "image/png",
"data": "<base64>"
}
]
}
],
"session_id": "optional-session-uuid",
"send_context": true,
"summarize_context": false
}| Field | Type | Required | Description |
|---|---|---|---|
messages |
array | yes | Conversation messages. At least one message required. |
messages[].role |
string | yes | "user", "assistant", or "system" |
messages[].content |
string | yes | Message text |
messages[].attachments |
array | no | File attachments (base64-encoded) |
session_id |
string | no | Existing session ID. If omitted, a new session is created. |
send_context |
boolean | no | Whether to send previous conversation context to the agent (default: true) |
summarize_context |
boolean | no | Whether to summarize context before sending (default: false) |
Response: text/event-stream with AG-UI events. The threadId in RUN_STARTED contains the session ID.
Errors: 400 (missing agent ID, invalid body, no messages), 401 (unauthorized), 403 (access denied, GitHub token required), 404 (agent not found), 500 (session creation failed).
Note: Agents with require_github_token: true will return 403 with {"error":"github_token_required","message":"Connect your GitHub account to use this agent"} if the user has not linked a GitHub account.
Authentication required. Owner-only access (sessions are scoped to the user who created them).
Lists all sessions for the authenticated user with a specific agent.
Response 200:
{
"sessions": [
{
"session_id": "uuid",
"session_name": "My chat",
"user_id": "user@example.com",
"app_name": "my-agent",
"is_multi_agent": false,
"created_at": 1700000000,
"last_activity": 1700001000,
"message_count": 5
}
]
}Returns a session with all its messages.
Response 200:
{
"session_id": "uuid",
"session_name": "My chat",
"user_id": "user@example.com",
"app_name": "my-agent",
"is_multi_agent": false,
"created_at": 1700000000,
"last_activity": 1700001000,
"message_count": 5,
"messages": [
{
"role": "user",
"content": "Hello"
},
{
"role": "assistant",
"content": "Hi there!",
"tool_calls": [],
"tool_results": [],
"content_parts": [
{"type": "text", "text": "Hi there!"}
]
}
]
}Errors: 400 (missing IDs), 403 (access denied / not owner), 404 (not found), 500.
Renames a session.
Request Body:
{
"session_name": "New name"
}Response 200: Updated session object (same structure as GET).
Errors: 400 (missing fields), 403 (not owner), 404 (not found).
Deletes a session and all its messages.
Response: 204 No Content
Errors: 400 (missing IDs), 403 (not owner), 404 (not found).
Administrator authentication is required for every endpoint in this section.
GET /api/admin/groupslists all configured groups;GET /api/admin/groups/{id}returns one.POST /api/admin/groupscreates a group. The JSON body requiresid,name, and at least twoagent_ids; optional fields aredescription(maximum 2,000 characters),allowed_users,allowed_groups, andmax_turns(0uses the runtime default, maximum50). The description is published as the purpose of the group's MCP tool.PUT /api/admin/groups/{id}updatesname,description,agent_ids, andmax_turns, preserving the invariant that every group has at least two agents. Omittingdescriptionpreserves its current value.PUT /api/admin/groups/{id}/permissionsreplacesallowed_usersandallowed_groups.DELETE /api/admin/groups/{id}deletes the group.
Agent groups are created by administrators with POST /api/admin/groups and updated with PUT /api/admin/groups/{id}. A user chats with a group and an LLM moderator picks which member agents respond, in sequence, each seeing the previous replies. Group sessions are personal — each user only ever sees and resumes their own.
Lists the groups the caller is allowed to use.
Sends a message to the group; the moderator decides who answers.
Request Body:
{
"messages": [{ "role": "user", "content": "Is checkout healthy?" }],
"session_id": "optional — resume one of YOUR group sessions",
"agent_ids": ["optional", "roster", "override"]
}| Field | Type | Required | Description |
|---|---|---|---|
messages |
array | yes | Conversation messages; the last one is the new user turn |
session_id |
string | no | Resume an existing session you own in this group |
agent_ids |
array | no | @mention roster override: only these members may answer (omit = moderator chooses) |
Response: text/event-stream (AG-UI). Exactly one RUN_STARTED and one terminal RUN_FINISHED or RUN_ERROR. TEXT_MESSAGE_*, TOOL_CALL_*, and agent-originated CUSTOM events carry an agentId; CUSTOM moderator.select announces each turn and CUSTOM turn.error reports a failed agent turn without aborting the debate. A partial outcome emits CUSTOM debate.incomplete before the terminal event, with data.reason equal to timeout, max_turns, moderator_error, persistence_error, session_mapping_error, or all_agents_failed.
Errors: 403 (no access to the group, or session_id not owned by the caller), 404 (unknown session_id), 500 (session store error), 503 (no moderator LLM configured).
Lists the caller's own sessions in the group (personal — never other members').
The same moderated debate is exposed over MCP as one
group__<groupId>tool per accessible group.
Authentication required. Server-level permission check.
Lists all MCP servers the authenticated user has access to.
Response 200:
{
"servers": [
{
"id": "my-server",
"name": "My MCP Server",
"description": "A tool server",
"transport": "sse",
"status": "connected",
"status_error": "",
"tool_count": 5
}
]
}Lists all tools available on an MCP server.
Response 200:
{
"tools": [
{
"name": "search",
"description": "Search the web",
"inputSchema": {...}
}
]
}Errors: 403 (access denied), 404 (server not found).
Reconnects to an MCP server (useful after connection failures).
Response 200: Server info object (same structure as list item).
Errors: 403 (access denied), 404 (server not found), 500 (reconnect failed).
Sends a chat message to an MCP server. The API orchestrates the LLM + tool calling loop and streams the response as AG-UI SSE events.
Request Body:
{
"messages": [
{"role": "user", "content": "Search for the latest news"}
],
"model_id": "gpt-4o",
"session_id": "optional-session-uuid"
}| Field | Type | Required | Description |
|---|---|---|---|
messages |
array | yes | Conversation messages |
model_id |
string | no | LLM model ID to use (falls back to default chat model) |
session_id |
string | no | Existing session ID |
Response: text/event-stream with AG-UI events (including tool call events).
Errors: 400 (invalid body, no messages), 403 (access denied), 404 (server not found).
Authentication required. Owner-only access.
Lists sessions for a specific MCP server.
Response 200:
{
"sessions": [...]
}Returns an MCP session with messages.
Response 200: Session object.
Errors: 403 (not owner), 404 (not found).
Renames an MCP session.
Request Body:
{
"session_name": "New name"
}Response 200: Updated session object.
Deletes an MCP session.
Response: 204 No Content
Authentication required.
Sends a chat message using tools from multiple MCP servers simultaneously.
Request Body:
{
"messages": [
{"role": "user", "content": "Search and analyze data"}
],
"model_id": "gpt-4o",
"server_ids": ["server-1", "server-2"],
"session_id": "optional-session-uuid"
}| Field | Type | Required | Description |
|---|---|---|---|
messages |
array | yes | Conversation messages |
model_id |
string | no | LLM model ID |
server_ids |
array | yes | At least 2 MCP server IDs |
session_id |
string | no | Existing session ID |
Response: text/event-stream with AG-UI events. Tool call events include serverId to identify which server owns the tool.
Errors: 400 (invalid body, fewer than 2 servers), 403 (access denied), 404 (server not found).
Lists all multi-MCP sessions for the authenticated user.
Response 200:
{
"sessions": [...]
}Returns a multi-MCP session with messages.
Response 200: Session object.
Errors: 403 (not owner), 404 (not found).
Renames a multi-MCP session.
Request Body:
{
"session_name": "New name"
}Response 200: Updated session object.
Deletes a multi-MCP session.
Response: 204 No Content
Authentication required. Admin role required.
Lists all agents with full configuration (including sensitive fields).
Response 200:
{
"agents": [
{
"id": "my-agent",
"name": "My Agent",
"description": "A helpful agent",
"category": "general",
"protocol": "rest",
"endpoint": "https://agent.example.com",
"agent_card_path": "",
"forward_authorization": false,
"require_github_token": false,
"pipeline_final_agent": "",
"adk_app_name": "",
"adk_user_id": "",
"headers": {},
"rate_limit": null,
"health_check": null,
"polling": null,
"allowed_users": ["user@example.com"],
"allowed_groups": ["group-a"],
"status": "healthy"
}
]
}Returns a single agent with full admin details.
Response 200: Admin agent response object (same structure as list item).
Errors: 404 (not found).
Creates a new agent.
Request Body:
{
"id": "new-agent",
"name": "New Agent",
"description": "Description",
"category": "general",
"protocol": "rest",
"endpoint": "https://agent.example.com",
"agent_card_path": "",
"forward_authorization": false,
"require_github_token": false,
"pipeline_final_agent": "",
"adk_app_name": "",
"adk_user_id": "",
"headers": {"X-Api-Key": "secret"},
"rate_limit": {"requests_per_minute": 60, "requests_per_hour": 1000},
"health_check": {"enabled": true, "endpoint": "/health", "interval_seconds": 30, "timeout_seconds": 5},
"polling": {"interval_ms": 500, "timeout_seconds": 120},
"allowed_users": ["user@example.com"],
"allowed_groups": ["*"]
}| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique agent identifier |
name |
string | yes | Display name |
description |
string | no | Agent description |
category |
string | no | Agent category |
protocol |
string | yes | "rest", "a2a", or "adk" |
endpoint |
string | yes | Agent base URL |
agent_card_path |
string | no | Path to agent-card.json (A2A only) |
forward_authorization |
boolean | no | Forward user's JWT to agent |
require_github_token |
boolean | no | Require linked GitHub account |
pipeline_final_agent |
string | no | Final agent ID for pipeline mode |
adk_app_name |
string | no | ADK app name |
adk_user_id |
string | no | ADK user ID |
headers |
object | no | Static headers to send to agent |
rate_limit |
object | no | Rate limit configuration |
health_check |
object | no | Health check configuration |
polling |
object | no | Polling configuration (A2A only) |
allowed_users |
array | no | Email addresses with access |
allowed_groups |
array | no | Group names with access |
Response 201: Admin agent response object.
Errors: 400 (missing required fields, invalid body).
Updates an existing agent.
Request Body: Same as POST (except id is taken from the URL path).
Response 200: Updated admin agent response object.
Errors: 400 (invalid body), 500 (update failed).
Deletes an agent.
Response: 204 No Content
Errors: 404 (not found).
Updates the permission lists for an agent.
Request Body:
{
"allowed_users": ["user@example.com"],
"allowed_groups": ["group-a", "*"]
}Response 200:
{
"allowed_users": ["user@example.com"],
"allowed_groups": ["group-a", "*"]
}Errors: 400 (invalid body), 500 (update failed).
Authentication required. Admin role required.
Lists all MCP servers with full configuration.
Response 200:
{
"servers": [
{
"id": "my-server",
"name": "My MCP Server",
"description": "Tool server",
"transport": "sse",
"url": "https://mcp.example.com",
"headers": {},
"allowed_users": [],
"allowed_groups": ["*"],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}Returns a single MCP server with full details.
Response 200: MCP server object.
Errors: 404 (not found).
Creates a new MCP server.
Request Body:
{
"id": "new-server",
"name": "New MCP Server",
"description": "Description",
"transport": "sse",
"url": "https://mcp.example.com/sse",
"headers": {"Authorization": "Bearer token"},
"allowed_users": [],
"allowed_groups": ["*"]
}| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique server identifier |
name |
string | yes | Display name |
description |
string | no | Server description |
transport |
string | yes | Transport type (e.g., "sse") |
url |
string | yes | Server URL |
headers |
object | no | Static headers for connection |
allowed_users |
array | no | Email addresses with access |
allowed_groups |
array | no | Group names with access |
Response 201: MCP server object.
Errors: 400 (missing required fields).
Updates an existing MCP server.
Request Body: Same as POST (except id is taken from the URL path).
Response 200: Updated MCP server object.
Errors: 400 (invalid body), 500 (update failed).
Deletes an MCP server.
Response: 204 No Content
Errors: 404 (not found).
Updates permission lists for an MCP server.
Request Body:
{
"allowed_users": ["user@example.com"],
"allowed_groups": ["*"]
}Response 200:
{
"allowed_users": ["user@example.com"],
"allowed_groups": ["*"]
}Errors: 400 (invalid body), 500 (update failed).
Authentication required. Admin role required.
Providers hold reusable credentials shared by one or more models. The CRUD endpoints are:
GET /api/admin/llm-providersGET /api/admin/llm-providers/{id}POST /api/admin/llm-providersPUT /api/admin/llm-providers/{id}DELETE /api/admin/llm-providers/{id}
{
"id": "openai-production",
"name": "OpenAI Production",
"provider_type": "openai",
"api_key": "sk-...",
"endpoint": "",
"enabled": true
}provider_type is anthropic, google, openai, or custom. Native providers require an API key and do not accept an endpoint. Custom providers require an OpenAI-compatible HTTP(S) endpoint and may omit the API key. Keys are encrypted at rest and masked in responses. Deleting a provider referenced by a model returns 409 Conflict.
The schema migration keeps the legacy model credential columns synchronized for rollback. A rollback disables models whose Provider was disabled. Keyless Custom Endpoints require this release or newer because v0.7.1 required a non-empty key for every LLM connection.
Lists all LLM models and their selected provider. Credentials are not returned by model endpoints.
Response 200:
{
"models": [
{
"id": "gpt-4o",
"name": "GPT-4o",
"provider_id": "openai-production",
"provider_name": "OpenAI Production",
"provider_type": "openai",
"model": "gpt-4o",
"role": "chat",
"enabled": true,
"is_default": true
}
]
}Returns a single LLM model with provider metadata and no credentials.
Response 200: LLM model object.
Errors: 404 (not found).
Creates a new LLM model.
Request Body:
{
"id": "gpt-4o",
"name": "GPT-4o",
"provider_id": "openai-production",
"model": "gpt-4o",
"role": "chat",
"enabled": true,
"is_default": true
}| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique model identifier |
name |
string | yes | Display name |
provider_id |
string | yes | Shared provider configuration |
model |
string | yes | Provider model name |
role |
string | no | Model role: "chat" (default), "summarizer", "file_processor", "chart_extractor", "session_namer", or "moderator" (group moderator) |
enabled |
boolean | no | Whether the model is active |
is_default |
boolean | no | Whether this is the default model for its role |
Response 201: LLM model object.
Errors: 400 (missing required fields).
Updates an existing LLM model or reassigns it to another provider.
Request Body: Same as POST (except id is taken from the URL path).
Response 200: Updated LLM model object.
Errors: 400 (invalid body), 404 (not found), 500 (update failed).
Deletes an LLM model.
Response: 204 No Content
Errors: 404 (not found).
Authentication and the admin role are required. GET /api/admin/settings returns every supported runtime key with its type, default, bounds, and effective value. PUT /api/admin/settings accepts an object of string overrides, validates the complete request, persists it atomically, and reloads the in-memory cache.
{
"group_debate_timeout_api": "15m",
"group_max_turns_api": "8",
"group_max_turns_mcp": "4"
}Unknown keys and invalid duration/integer values return 400; persistence or cache-reload failures return 500.
Authentication required. Admin role required.
Lists all users in the system.
Response 200:
{
"users": [
{
"id": "uuid",
"email": "user@example.com",
"role": "user",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
}Updates a user's role.
Request Body:
{
"role": "admin"
}| Field | Type | Required | Description |
|---|---|---|---|
role |
string | yes | "admin" or "user" |
Response 200:
{
"email": "user@example.com",
"role": "admin"
}Errors: 400 (invalid role), 404 (user not found).