@davepi/agent
v0.6.1
Published
Chat agent that ships pre-wired against a dAvePi backend. HTTP /chat + Slack + Telegram channels, OpenAI / Anthropic / OpenRouter / Ollama (local) providers, service-account or per-user OAuth-link auth. Consumes the dAvePi MCP server, so ACL/scope/tenant
Maintainers
Readme
@davepi/agent
A chat agent that ships pre-wired against a dAvePi backend. The agent connects to davepi's built-in MCP server, so every schema's CRUD + relations + aggregations + audit + file ops are available as tools out of the box — and tenant isolation and ACL are enforced server-side, not in the prompt.
What you get
- HTTP
/chatendpoint with Server-Sent Events streaming. - Slack bot via
@slack/bolt(app mention + DM). Render tools translate to Block Kit tables and QuickChart images. - Telegram bot via the Bot API over long-polling — no public URL needed,
just a
TELEGRAM_BOT_TOKEN(DM + group @-mention). Render tools translate to monospace tables and QuickChart photos. - WhatsApp / Embeddable widget templates in
lib/channels/templates/— stubs with a one-screen recipe for filling in. - OpenAI, Anthropic, OpenRouter, and Ollama (local) providers via the
Vercel AI SDK. Switch via
LLM_PROVIDER. OpenRouter fronts hundreds of models behind one key — see Models via OpenRouter below. Ollama runs against a local model server with no API key — see Local models via Ollama. - Two auth modes:
service— one JWT (orX-Client-Id) for the whole bot. Right for an anonymous storefront widget where every visitor sees the same role-scoped slice.per-user— each channel user maps to a real davepi user via an OAuth-style link flow. Refresh tokens stored locally; access tokens minted on demand and cached.
- Tool router for backends with too many schemas: above the configured limit (default 40), the agent first picks a resource, then loads that resource's tools.
- Structured render tools (
render_table,render_chart) so the model can ask for a visualization without each channel reinventing layout. - Team delegation (optional): give an agent a
teamroster and it becomes a leader that candelegatesubtasks to specialist agents in parallel, assess the results, and iterate. Specialists share the leader's identity and differ only by persona / skills / memory — capability, never privilege. See the Teams and delegation guide.
ACL boundary — design rule
The JWT (or X-Client-Id) is the access boundary. The agent never
re-implements ACL checks and never tries to constrain results via prompt
text. If you want a service-account bot to only see "published" rows,
declare a schema.acl.scope[role] filter on the davepi side — the MCP
server applies it on every read and the agent never sees the filter
itself. The wrong pattern (and the one to avoid) is a broad service token
plus "only show user X's data" in the prompt; that's a confused-deputy
bug waiting to happen.
Quick start
# In your davepi project, install the agent as a dev tool:
npm install @davepi/agent
# Minimal env:
export DAVEPI_URL=http://localhost:5050
export ANTHROPIC_API_KEY=sk-ant-...
export DAVEPI_BEARER=dpk_... # a davepi API key (recommended: long-lived, revocable)
# OR for anonymous reads via an apiClient role:
# export DAVEPI_CLIENT_ID=<client-id>
# Start the agent:
npx davepi-agent
# HTTP /chat is now listening on :5070
# Talk to it:
curl -N -X POST http://localhost:5070/chat \
-H 'content-type: application/json' \
-d '{"message":"What products do we have?"}'HTTP endpoints
| Method + path | Purpose |
| ------------------------- | ------------------------------------------------------------------------------------------- |
| GET /health | Liveness + agent identity (agentKey, name, isTeam, team[]) for the admin portal. |
| POST /chat | SSE-streaming chat. Body: { message, history?, stream?, conversationId? }. |
| GET /conversations | List past conversations (metadata + preview, newest first) for a resume UI. |
| GET /conversations/:id | One conversation's full transcript, shaped as POST /chat's history so you can resume it. |
| GET/POST /link/:nonce | Per-user account-linking form (per-user mode only). |
Resuming a conversation. History persists automatically in per-user mode (keyed on the session cookie). In service mode (e.g. the admin chat), send a stable conversationId on POST /chat — a per-browser-session UUID works — and the transcript is saved to davepi's conversation collection under that id. To resume: GET /conversations to list, GET /conversations/:id to load the transcript, then continue by posting to /chat with the same conversationId. Omit conversationId and nothing is persisted server-side (the client round-trips history itself). Service-mode conversations are visible to any CORS-allowed caller (one shared service identity); per-user conversations are scoped to the linked user.
Configuration (env)
Required:
| Variable | Purpose |
| ----------------- | ------------------------------------------------- |
| DAVEPI_URL | Base URL of the davepi backend |
| LLM_PROVIDER | anthropic (default), openai, openrouter, or ollama |
| ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY | Provider key (not needed for ollama) |
| LLM_MODEL | Model id. Optional for anthropic / openai / openrouter (sensible defaults); required for ollama |
Service auth (default) — set one of:
| Variable | Purpose |
| ------------------ | ------------------------------------------------ |
| DAVEPI_BEARER | Bearer credential for a davepi user. Use a dpk_ API key (long-lived, revocable) — a JWT works but expires (≤2h) and the agent won't rotate it, so a long-running agent dies. |
| DAVEPI_CLIENT_ID | Public client id for anonymous reads |
Per-user auth:
| Variable | Purpose |
| -------------------------- | ----------------------------------------------------------------------------- |
| AGENT_AUTH_MODE=per-user | Switch on per-user mode |
| AGENT_LINK_BASE_URL | Public base URL of the agent itself (where GET /link/:nonce is served) |
| AGENT_SESSION_SECRET | Required for HTTP per-user mode — HMAC key for the signed session cookie |
| AGENT_COOKIE_SECURE | true (default) — emits Secure on the session cookie. Set false for HTTP-only dev |
| STORE_URL | Where to persist refresh tokens. file:./davepi-agent-store.json (default) or memory: |
Persona & memory (optional):
| Variable | Purpose |
| ----------------------------------- | ------------------------------------------------------------------------------------------------ |
| AGENT_KEY | Which agent this process is (e.g. support). Selects the agentPersona / agentMemory rows used as prompt slots. Unset → built-in default prompt and no persisted snapshot |
| AGENT_PERSONA_CACHE_TTL_SECONDS | Per-process cache TTL for the persona lookup (default 60). Set 0 to fetch on every turn (strict immediacy) |
| AGENT_PERSIST_CONVERSATIONS | Persist history + the frozen prompt snapshot to davepi's conversation schema (default true). false keeps the channel-managed in-memory round-trip only |
| AGENT_SESSION_IDLE_SECONDS | Idle gap after which a returning user is a new session and the snapshot is re-frozen, picking up memory/profile writes from the prior session (default 1800) |
| LLM_PROMPT_CACHING | Anthropic prompt caching on the frozen snapshot prefix (default true, Anthropic provider only). false to disable |
Models via OpenRouter
Set LLM_PROVIDER=openrouter to route through OpenRouter,
a single API in front of hundreds of models from many vendors. The agent reuses the
bundled @ai-sdk/openai provider pointed at OpenRouter's OpenAI-compatible
/api/v1 endpoint, so there's no extra dependency — you just need an
OPENROUTER_API_KEY.
| Variable | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------- |
| OPENROUTER_API_KEY | Required — your OpenRouter key (sk-or-...) |
| LLM_MODEL | OpenRouter vendor/model id (e.g. anthropic/claude-3.5-sonnet, openai/gpt-4o, google/gemini-2.0-flash-001). Defaults to openrouter/auto (OpenRouter picks a model per request) |
| OPENROUTER_BASE_URL| Default https://openrouter.ai/api/v1. Override for a proxy |
| OPENROUTER_SITE_URL| Optional — sent as HTTP-Referer for OpenRouter's leaderboard attribution |
| OPENROUTER_APP_NAME| Optional — sent as X-Title for OpenRouter's leaderboard attribution |
LLM_PROVIDER=openrouter LLM_MODEL=anthropic/claude-3.5-sonnet \
OPENROUTER_API_KEY=sk-or-... \
DAVEPI_URL=http://localhost:5050 \
DAVEPI_BEARER=dpk_... \
npx davepi-agentLLM_PROMPT_CACHING is a no-op for OpenRouter — prompt caching is applied only on
the native Anthropic provider. Pick a model with solid tool-calling support (the
big frontier models all qualify); the tool router keeps the exposed surface under
AGENT_TOOL_LIMIT regardless.
Local models via Ollama
Set LLM_PROVIDER=ollama to run the agent against a local Ollama
server. The agent reuses the bundled @ai-sdk/openai provider pointed at Ollama's
OpenAI-compatible /v1 endpoint, so there's no extra dependency and no API key.
| Variable | Purpose |
| ------------------ | ---------------------------------------------------------------------------------------- |
| LLM_MODEL | Required — Ollama has no universal default. The exact name you ran ollama pull with (e.g. llama3.1, qwen2.5) |
| OLLAMA_BASE_URL | Default http://localhost:11434/v1. Override for a remote Ollama or a reverse proxy |
| OLLAMA_API_KEY | Optional — only set when fronting Ollama with an auth proxy. Ollama itself ignores it |
ollama pull llama3.1
ollama serve
LLM_PROVIDER=ollama LLM_MODEL=llama3.1 \
DAVEPI_URL=http://localhost:5050 \
DAVEPI_BEARER=<jwt> \
npx davepi-agentLLM_PROMPT_CACHING is a no-op for Ollama — prompt caching is an Anthropic feature
only. Tool-calling fidelity depends on the model: llama3.1 and qwen2.5 work well
with the MCP tool surface; smaller models may struggle on backends with many schemas
(the tool router keeps the exposed surface under AGENT_TOOL_LIMIT).
Memory & the frozen snapshot
Once AGENT_KEY is set the agent reads a per-tenant memory row (agentMemory,
slow-changing facts about the account) and a per-end-user profile row
(customerProfile, preferences/notes — shared across the tenant's agents) and
folds them into the system prompt alongside the persona. These five slots are
snapshotted once at session start and frozen for the whole conversation, so
the prefix stays byte-stable and Anthropic prompt caching keeps hitting. The
agent self-authors memory/profiles through the schema-generated MCP tools (e.g.
update_agentMemory); because the snapshot is frozen, a write takes effect on
the next session, never mid-conversation.
Live vs. remembered. Treat memory/profile/persona as slow-changing background that may be slightly stale — never as live system state. For anything that changes (order status, ticket state, inventory, balances), the agent calls a tool to read it fresh. Snapshotted text shapes tone and recall; it is never an access-control or live-data mechanism (the JWT / client id remains the boundary).
Linking flow (per-user mode): on first contact from an unlinked user the
agent returns a one-time link URL (<agent>/link/<nonce>). The user opens
it, signs in via a small HTML form, and the agent calls davepi's
POST /login server-side to obtain the refresh token. The refresh token
never crosses the browser. For HTTP-channel users, link completion sets
an HMAC-signed davepi_agent_session cookie (HttpOnly, SameSite=Lax)
that /chat reads on every subsequent request. /chat ignores any
caller-supplied channelUserId — the cookie is the trust boundary.
Channels:
| Variable | Purpose |
| ----------------------- | ------------------------------------------------------ |
| AGENT_HTTP_ENABLED | true (default) / false |
| AGENT_HTTP_PORT | HTTP port (default 5070) |
| AGENT_CORS_ORIGINS | Comma-separated allowlist |
| SLACK_BOT_TOKEN | Enables the Slack channel when set |
| SLACK_SIGNING_SECRET | Required when Slack is enabled (HTTP mode) |
| SLACK_APP_TOKEN | App-level token for socket mode |
| SLACK_SOCKET_MODE | true to use socket mode |
| SLACK_PORT | Slack HTTP port (default 5061) |
| AGENT_TRACE | Trace-log every turn's inputs, tool calls/results, and final output. On by default outside NODE_ENV=production/test; true/false to override |
Tools:
| Variable | Purpose |
| ------------------------- | ---------------------------------------------------- |
| AGENT_TOOL_LIMIT | Above this many tools, switch to routed mode (40) |
| AGENT_INCLUDE_RENDER | Inject render_table + render_chart tools (true) |
Programmatic use
const { startAgent, runTurn, createAgent } = require('@davepi/agent');
// Start with all configured channels:
await startAgent();
// Or build the pieces and drive runTurn() yourself:
const { config, model, mcpClient, auth } = await createAgent({
llm: { provider: 'openai', model: 'gpt-4o-mini' },
});
const out = await runTurn({
config, model, mcpClient, auth,
channelCtx: { channel: 'my-channel', channelUserId: 'user-123' },
history: [],
userMessage: 'Show me last week\'s orders as a chart',
onEvent: console.log,
});Slack setup checklist
- Create a Slack app at https://api.slack.com/apps.
- OAuth & Permissions scopes:
app_mentions:read,chat:write,im:history,im:write,users:read. - Event Subscriptions: enable; subscribe to
app_mentionandmessage.im. - Install the app to your workspace; copy the Bot User OAuth Token
to
SLACK_BOT_TOKENand the Signing Secret toSLACK_SIGNING_SECRET. - For local dev without a public URL, set
SLACK_SOCKET_MODE=trueand provideSLACK_APP_TOKEN. - Start the agent;
@-mention it in a channel or DM it.
Telegram setup checklist
- Message @BotFather, send
/newbot, and follow the prompts to name your bot. - Copy the HTTP API token to
TELEGRAM_BOT_TOKEN. - Start the agent. It long-polls the Bot API — no public URL or webhook
needed. DM the bot, or add it to a group and
@-mention it.
Optional tuning: TELEGRAM_POLL_TIMEOUT (long-poll hold, default 30s),
TELEGRAM_POLL_BACKOFF_MS (retry pause after a failed poll, default 2000),
TELEGRAM_ENABLED=false to force the channel off even with a token set.
Proactive / scheduled agents (cron + attached skills)
Agents don't have to wait to be spoken to. Pair @davepi/agent with
davepi-plugin-cron to run a fresh agent on a
schedule that follows a named, approved skill (a governed runbook) and
posts its output to Slack — follow-ups, SLA digests, end-of-day summaries.
const { createAgent } = require('@davepi/agent');
const cron = require('davepi-plugin-cron');
const agent = await createAgent({ agent: { key: 'support' } });
cron.register('daily-sla-digest', {
schedule: '0 9 * * 1-5', // 9am on weekdays
handler: agent.scheduledSkill({
skill: 'Daily SLA digest', // name of an *approved* skill for this agentKey
slackChannel: 'C0123456789', // channel id to post into
// prompt: 'optional override of the default autonomous preamble',
// threadTs: 'optional thread to post into',
}),
});Each tick:
- Loads the named skill through the agent's own MCP identity, filtered to
status: 'approved'— a draft/deprecated runbook is never fired. - Runs a fresh
runTurn(empty history, no end-user) with the persona loaded and the skill'sbodyinlined as the task. Live data is fetched with tools, not assumed from the snapshot. - Posts the reply (plus any
render_table/render_chartoutput) to Slack.
Tenant scoping is inherited, not re-implemented: the agent's service auth
owns exactly one tenant's data, so the skill lookup and the run are
tenant-scoped server-side like every other read. For a multi-tenant
deployment, register one job per tenant agent (each built with its own auth)
or pass an explicit channelCtx.
Service auth is the default and the expectation. A scheduled run has no
end-user, so the default cron context has no channelUserId. Per-user auth
resolves the agent's identity from the end-user, so a per-user agent is
rejected at registration unless you pass an explicit channelCtx with a
channelUserId (advanced: a job that acts as one specific linked user).
SLACK_BOT_TOKEN must be set (the poster reuses the bundled @slack/web-api
client); the full Slack channel doesn't have to be enabled. A run that
produces no output skips the post. Cancellation is cooperative: the cron
lease's AbortSignal is threaded into the run — forwarded to MCP tool calls
and the model stream — so if the lease is lost mid-run (another node took
over), in-flight tool calls and generation stop instead of continuing to
write, and nothing is posted (no double-post). Use
createScheduledHandler({ agent, ... }) directly if you'd rather not go
through agent.scheduledSkill.
License
ISC
