Data Model & Contracts
The wire-level details: environment variables, Postgres schema, MCP tool contracts, the MCP session/push model, and Inngest event schemas. This document is the authoritative contract reference until the api/ and architecture/ folders catch up.
Environment & Secrets
| Variable | Purpose | Source |
|---|---|---|
NODE_ENV | production / development | static |
PORT | HTTP listen port (default 3000, bound to 127.0.0.1) | static |
PUBLIC_BASE_URL | e.g. https://bot.example.com — used for Telegram setWebhook and Inngest serve() config | static |
TELEGRAM_BOT_TOKEN | from BotFather | BotFather |
TELEGRAM_WEBHOOK_SECRET | passed to setWebhook; validated on every update via X-Telegram-Bot-Api-Secret-Token | generated (32 bytes) |
MCP_AUTH_TOKEN | bearer token expected on /mcp | generated (32 bytes) |
INNGEST_EVENT_KEY | used by server to send events to Inngest | Inngest dashboard |
INNGEST_SIGNING_KEY | used by Inngest SDK to verify incoming /api/inngest calls | Inngest dashboard |
DATABASE_URL | Postgres connection string | local pg or managed |
ADMIN_TELEGRAM_USER_ID | bootstraps the first admin without a DB row | manual |
LOG_LEVEL | pino level (info default) | static |
Storage: /etc/telegram-mcp/env with mode 600, owned by service user, loaded via EnvironmentFile= in the systemd unit. .env.example committed; real secrets never in the repo. Rotation = edit file, systemctl restart.
Data Model
Initial Postgres schema, deliberately narrow. Migrations live in src/db/migrations/, run via node-pg-migrate.
-- users: identity + role
CREATE TABLE users (
telegram_user_id BIGINT PRIMARY KEY,
username TEXT,
first_name TEXT,
status TEXT NOT NULL CHECK (status IN ('pending','active','blocked')) DEFAULT 'pending',
role TEXT NOT NULL CHECK (role IN ('admin','user')) DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
approved_at TIMESTAMPTZ,
approved_by BIGINT REFERENCES users(telegram_user_id)
);
-- chat_messages: archived for get_chat_history (Telegram does not expose history)
CREATE TABLE chat_messages (
id BIGSERIAL PRIMARY KEY,
chat_id BIGINT NOT NULL,
telegram_user_id BIGINT NOT NULL,
message_id BIGINT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('inbound','outbound')),
kind TEXT NOT NULL, -- 'text' | 'photo' | 'document' | 'callback'
text TEXT,
payload JSONB, -- raw normalized event
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX chat_messages_user_time_idx ON chat_messages (telegram_user_id, created_at DESC);
-- memory_kv: per-user key-value store (Phase 4 v1)
CREATE TABLE memory_kv (
telegram_user_id BIGINT NOT NULL REFERENCES users(telegram_user_id) ON DELETE CASCADE,
key TEXT NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (telegram_user_id, key)
);
-- jobs: mirror of Inngest runs (state + result for get_job_status, session for push routing)
CREATE TABLE jobs (
id UUID PRIMARY KEY,
telegram_user_id BIGINT NOT NULL REFERENCES users(telegram_user_id),
type TEXT NOT NULL, -- 'image.generate' etc.
status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','cancelled')),
payload JSONB NOT NULL,
result JSONB,
error TEXT,
inngest_run_id TEXT, -- correlates back to the Inngest dashboard
mcp_session_id TEXT, -- which agent session created it (for push routing)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX jobs_user_status_idx ON jobs (telegram_user_id, status);
-- audit_log: every admin action, every blocked-by-policy attempt
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
actor_id BIGINT,
action TEXT NOT NULL, -- 'approve_user' | 'set_role' | 'blocked_request' | ...
subject_id BIGINT,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);MCP Tool Design
Messaging Tools
| Tool | Signature |
|---|---|
send_message | (chatId, text, options?) |
send_image | (chatId, file) |
send_document | (chatId, file) |
send_buttons | (chatId, layout) |
edit_message | (chatId, messageId, newText) |
create_button_menu | (chatId, menuConfig) |
Context / Memory Tools
| Tool | Signature |
|---|---|
get_user_context | (userId) |
update_memory | (userId, key, value) |
get_chat_history | (userId, limit?) |
store_memory | (userId, payload) |
Job Tools (Async)
| Tool | Signature |
|---|---|
create_job | (type, payload) |
get_job_status | (jobId) |
cancel_job | (jobId) |
Admin Tools
| Tool | Signature |
|---|---|
approve_user | (userId) |
set_role | (userId, role) |
Tool Contracts (detailed)
All tool inputs are parsed with Zod before any side effect. Errors are returned as JSON-RPC errors with a stable code and a user-safe message. Common error codes: INVALID_INPUT, UNAUTHORIZED, NOT_FOUND, RATE_LIMITED, UPSTREAM_FAILURE.
send_message
input : { chatId: number, text: string,
options?: { parseMode?: 'Markdown'|'HTML',
replyToMessageId?: number,
disableNotification?: boolean,
replyMarkup?: InlineKeyboardMarkup } }
output : { messageId: number, chatId: number, sentAt: string }
errors : INVALID_INPUT, NOT_FOUND (chat), USER_BLOCKED_BOT, RATE_LIMITED
send_image
input : { chatId: number, image: ImageSource, caption?: string,
options?: { parseMode?, replyMarkup?, disableNotification? } }
output : { messageId, chatId, fileId, sentAt }
errors : INVALID_INPUT, NOT_FOUND, USER_BLOCKED_BOT, UPSTREAM_FAILURE
note : ImageSource is one of { url: string } | { fileId: string }
| { bytesBase64: string, mimeType: string }. See open question on image source.
send_document
input : { chatId, file: FileSource, caption?, options? }
output : { messageId, chatId, fileId, sentAt }
send_buttons
input : { chatId, text, layout: { rows: Button[][] } }
Button = { label: string, callbackData: string }
| { label: string, url: string }
output : { messageId, chatId }
edit_message
input : { chatId, messageId, newText: string,
options?: { parseMode?, replyMarkup? } }
output : { messageId, chatId, editedAt: string }
create_button_menu
input : { chatId, menuConfig: { title: string, items: Button[] } }
output : { messageId, chatId }
note : convenience wrapper around send_buttons with sensible defaults.
get_user_context
input : { userId: number }
output : { user: { id, username, firstName, status, role,
createdAt, approvedAt },
memory: Record<string, JSON>,
recentMessages: ChatMessage[] } // last 20 by default
update_memory
input : { userId, key: string, value: JSON }
output : { key, updatedAt: string }
note : upsert. Use store_memory for bulk writes.
store_memory
input : { userId, entries: { key: string, value: JSON }[] }
output : { count: number, updatedAt: string }
get_chat_history
input : { userId, limit?: number (default 50, max 500),
before?: string (ISO timestamp) }
output : { messages: ChatMessage[] }
note : reads from chat_messages table (we archive everything inbound + outbound).
create_job
input : { type: 'image.generate' | string,
payload: JSON,
userId: number } // telegram_user_id for concurrency key
output : { jobId: UUID, status: 'queued', acceptedAt: string }
note : emits Inngest event `job.requested`. Returns immediately.
get_job_status
input : { jobId: UUID }
output : { jobId, type, status, result?, error?,
createdAt, updatedAt }
cancel_job
input : { jobId: UUID }
output : { jobId, status: 'cancelled' }
note : best-effort. Sets DB status; the Inngest run finishes its current step
and stops at the next checkpoint. Only valid while status in
('queued','running').
approve_user
input : { userId: number }
output : { userId, status: 'active', approvedAt }
errors : UNAUTHORIZED (caller not admin), NOT_FOUND
set_role
input : { userId, role: 'admin'|'user' }
output : { userId, role }
errors : UNAUTHORIZED, NOT_FOUNDMCP Session & Push Model
Routing job-completion notifications back to the correct agent connection is the only non-trivial piece of MCP plumbing. Approach:
- Every MCP connection gets a
sessionIdfrom the streamable HTTP transport. create_jobpersists the caller’smcp_session_idon thejobsrow.- An in-process map
Map<sessionId, pushFn>tracks active sessions; populated on connect, cleared on disconnect. - A background consumer subscribes to Inngest’s
job.completed/job.failedevents (Inngest sends them back to us via the same/api/inngestendpoint as a no-op function whose only job is to forward to the in-process notifier). - On receipt: look up the job row → resolve
mcp_session_id→ if active, callpushFn; if not, drop (the agent will reconcile viaget_job_statuson reconnect).
Notification shape (JSON-RPC, server → client, no ack expected):
method: "notifications/job_status"
params: { jobId, status: 'succeeded'|'failed'|'cancelled',
result?, error? }Constraints and notes:
- Sessions are in-process state. A server restart drops them. The
jobstable is the source of truth, so the agent always has a path to reconcile (pollget_job_statuson reconnect, or list jobs by user). - For v1 we expect 1–2 agent sessions max. No Redis pub/sub needed.
- Multi-instance deploy would need a shared bus to route notifications to the right instance — flagged as future work, not v1.
Inngest Event Schemas
All events live in src/jobs/events.ts with matching Zod schemas. Naming convention: <domain>.<verb> in past tense (job.requested, job.completed). Version suffix (.v2) added only on breaking changes; first revision has no suffix.
job.requested // emitted by MCP server when create_job runs
data: {
jobId: UUID,
type: 'image.generate' | string,
userId: number, // telegram_user_id, used as concurrency key
mcpSessionId: string, // for routing the eventual notification
payload: object // type-specific input
}
job.completed // emitted by the worker function on success
data: {
jobId: UUID,
type: string,
userId: number,
result: object
}
job.failed // emitted by the worker function on terminal failure
data: {
jobId: UUID,
type: string,
userId: number,
error: { code: string, message: string }
}
job.cancelled // Phase 5+ when cancel_job is wired
data: { jobId, type, userId, reason: string }Function wiring:
| Function | Trigger | Concurrency | Retries |
|---|---|---|---|
image.generate | job.requested where data.type == 'image.generate' | key=event.data.userId, limit=1 | default Inngest (4 attempts, exponential) |
notify.job_status | job.completed OR job.failed OR job.cancelled | none (fan-in) | 3 attempts |
notify.job_status is the only “system” function; everything else is a real job type.