Channels: run one workflow over HTTP, Telegram, webhooks¶
teff[channels] binds a single durable Assistant (built from a
workflow.yaml) to many transports, so interrupt handling, checkpoints and
message history behave identically on every surface. You get:
- HTTP/SSE —
/api/chat, a streaming/api/chat/stream, run state GET/DELETE, and a health check. - Telegram — a bot via long-polling or a webhook, with one session per chat and owner scoping by user id.
- Generic webhooks — JSON payloads validated against a JSON Schema and
routed into a turn with a configurable
owner:.
Every turn answers with the same shape across all channels:
{"session_id": "...", "waiting": false, "message": "<the reply>"}
When a workflow pauses on an interrupt, waiting is true and message
carries the prompt; the client resumes simply by sending the operator's
answer as the next message.
Install¶
pip install "teff[channels]"
Zero-code: channels: in YAML¶
channels:
http:
enabled: true
host: 0.0.0.0
port: 8000
telegram:
enabled: true
token: "${TELEGRAM_BOT_TOKEN}"
mode: polling
Then run the whole thing from one command:
teff serve -f workflow.yaml --host 0.0.0.0 --port 8000
teff bot -f workflow.yaml --token-env TELEGRAM_BOT_TOKEN --mode polling
teff chat -f workflow.yaml
teff chat is the terminal channel: the same durable Assistant as a plain
REPL. Each line is one turn, a paused workflow (interrupt) asks in-chat and
resumes on your answer.
teff new channels scaffolds this YAML-first template; a code-first variant
is available too.
Code-first: the HTTP/SSE channel¶
from teff.channels import build_assistant, create_http_app, HTTPChannel
assistant = build_assistant("workflow.yaml")
# standalone app
app = create_http_app(assistant)
# or a mounted router inside your own FastAPI app
channel = HTTPChannel(assistant)
app.include_router(channel.router)
Sessions are scoped to the caller via the X-User-Id header (default
owner "default" when absent), and the session id is always generated
server-side so callers cannot hijack another user's session.
Hooks: auth and per-turn kwargs¶
create_http_app / create_http_router / HTTPChannel accept:
dependencies— FastAPIDependsobjects applied to every endpoint exceptGET /api/health(health stays open for probes).turn_kwargs— a(owner, session_id) -> kwargsfactory whose result is merged into everyAssistant.run/Assistant.streamcall. Use it to attach an observabilitytracer/on_llm_payloadper turn.
from fastapi import Depends, Header, HTTPException
def require_key(x_api_key: str | None = Header(default=None, alias="X-API-Key")):
if x_api_key != "secret":
raise HTTPException(status_code=401, detail="missing api key")
app = create_http_app(assistant, dependencies=[Depends(require_key)])
def traced(owner: str, session_id: str) -> dict:
return {"tracer": my_tracer(owner, session_id)}
app = create_http_app(assistant, turn_kwargs=traced)
Telegram¶
from teff.channels import TelegramChannel, build_assistant
bot = TelegramChannel(build_assistant("workflow.yaml"), token="...")
# long-poll
await bot.poll()
# or webhook mode
await bot.set_webhook("https://example.com/hook")
Each Telegram chat maps to a session; the owner is the message sender's
user id (from.id, falling back to the chat id), so two people in the same
group never share a conversation.
Generic webhooks¶
from teff.channels import WebhookChannel, build_assistant
hook = WebhookChannel(
build_assistant("workflow.yaml"),
{
"input": {"message": "summarize: {text}"},
"owner": "header.X-User-Id",
"session_key": "text",
},
)
await hook.handle({"text": "the quick fox"}, headers={"X-User-Id": "alice"})
owner: accepts payload.<field>, header.<Name> (case-insensitive),
fixed:<value> or a literal; anything missing falls back to "default".
The optional JSON Schema in schema: validates the payload before the turn.
Growing a knowledge base: rag_ingest¶
rag only searches. The rag_ingest tool is the write side: it takes
document text (or a path to a csv/txt/pdf/excel file), chunks it, embeds
it and persists the vectors — so a Telegram message, webhook or terminal
turn can grow the store, and later rag turns answer from it. Declared in
tools: exactly like rag (same embedder:/store: config):
tools:
- type: rag_ingest
config:
embedder: { provider: ollama, model: nomic-embed-text }
store: { type: sqlite, path: data/vectors.db, dim: 768 }
Run it from a workflow step with tool_call, feeding AI-normalized text
from an llm_chat step before it:
steps:
- id: normalize
type: llm_chat
config:
system: "Turn this product row into one clean paragraph."
prompt: "Row:\n{row}"
output_key: doc_text
- id: ingest
type: tool_call
config:
tool: rag_ingest
args: { text: "{doc_text}" }
output_key: ingest_result
on_error: message
See examples/channels/rag_ingest for the full pipeline
(ingest a CSV row over a webhook, then query the grown base from any
channel) and examples/channels/supervisor for the
multi-agent supervisor wrapped in the channels: block.