Javis OS
Javis OS는 기업가를 돕는 에이전시 AI로, 업무를 지원하고 함께 성장합니다
회사용 AI 에이전트 팀과 개인 자금·시간·계획을 위한 프라이빗 AI 비서 제공, 스스로 작업하고 출력 전 확인
A team of AI agents for your company, and private AI assistants for your own money, week and plans. They work on their own and ask before anything goes out.
비즈니스TypeScript · JavaScriptGitHub @SinestreaPukk온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
An agentic AI platform for building AI employees. Create agents with a name, a job title, a personality,
responsibilities, and your company's documents — then put them in front of real
clients through a shareable link or an embeddable chat widget. When a client
reports a bug, asks for a feature, or needs a human, the agent records it and it
lands in your inbox.
Everything here runs live against a real model API. There are no mocked replies,
no seeded fake conversations, and no buttons that do nothing.README 전문 보기 ↗Agentic AI Platform
An agentic AI platform for building AI employees. Create agents with a name, a job title, a personality, responsibilities, and your company's documents — then put them in front of real clients through a shareable link or an embeddable chat widget. When a client reports a bug, asks for a feature, or needs a human, the agent records it and it lands in your inbox.
Everything here runs live against a real model API. There are no mocked replies, no seeded fake conversations, and no buttons that do nothing.
Naming: the product name and brand colour live in
src/lib/brand.ts, and the logo is derived from the source artwork byscripts/make-brand-assets.mjs. "Roster" remains the name of the page that lists your AI staff — that is a feature, not the product.
cp .env.example .env
# Fill in ANTHROPIC_API_KEY, then generate a session secret:
openssl rand -base64 32 # paste into AUTH_SECRET
docker compose up
Open http://localhost:3000, create an account, and you land in the agent wizard.
Three services come up: db (Postgres with pgvector), migrate (applies the
schema, then exits), and app (waits for migrate to succeed). The app refuses
to start with a named list of what is missing rather than serving something that
500s on the first message.
The schema step is a separate container on purpose. The runtime image is a slim standalone bundle without the Prisma CLI's dependency tree, and carrying that tree just to run one command at boot roughly doubles the image.
If ANTHROPIC_API_KEY is an organisation-level key rather than one scoped to
a single workspace, every request is rejected:
400 invalid_request_error — This API key is not scoped to a workspace, so this
request must include the anthropic-workspace-id header ...
Either create a key inside a workspace (Console → Settings → API keys, pick a
workspace) — the simpler option, nothing else to configure — or set
ANTHROPIC_WORKSPACE_ID to the workspace id from Console → Settings →
Workspaces. /api/health reports which of the two is in effect.
No Docker, no database server:
cp .env.example .env
# Set ANTHROPIC_API_KEY and AUTH_SECRET, then switch the datasource:
# DATABASE_PROVIDER=sqlite
# DATABASE_URL=file:./dev.db
npm install
npm run db:push # generates the SQLite schema and creates the file
npm run db:seed # optional: a demo admin, an agent, and a sample document
npm run dev
npm run inngest:dev # in a second terminal: the background job runtime
The second terminal matters. Anything scheduled or event-driven runs through
Inngest, and its dev server is what fires the
schedules locally (dashboard at http://localhost:8288). /api/health reports
jobs.alive: false until it has ticked once - see Background jobs.
npm run db:seed prints the demo login and writes
samples/northwind-returns.md, which you can upload to the seeded agent to see
grounded answers immediately.
| Variable | Required | Notes |
|---|---|---|
ANTHROPIC_API_KEY | yes | Server-side only. Never reaches the client bundle. |
ANTHROPIC_WORKSPACE_ID | only for org keys | An organisation-level key is rejected with a 400 unless every request names a workspace. Leave blank for a workspace-scoped key. See Workspace-scoped keys. |
AUTH_SECRET | yes | Signs session cookies. openssl rand -base64 32. |
DATABASE_URL | yes | Postgres connection string, or file:./dev.db. |
DATABASE_PROVIDER | yes | postgresql or sqlite. Must match DATABASE_URL. |
NEXT_PUBLIC_APP_URL | no | Used for share links; falls back to the request origin. |
ANTHROPIC_DEFAULT_MODEL | no | Default claude-sonnet-4-6. |
EMBEDDING_PROVIDER | no | local (default) or openai — see Retrieval. |
OPENAI_API_KEY | no | Needed for OpenAI-backed agents or OpenAI embeddings. |
NOTIFY_WEBHOOK_URLS | no | Comma-separated webhooks notified on escalation or a high/critical issue. Slack's incoming-webhook format. |
CHAT_RATE_LIMIT | no | Messages per client session per window. Default 20/min. |
.env is gitignored; .env.example is committed and is the canonical list.
Every admin surface belongs to exactly one project — its own roster, inbox and insights, with nothing shared between them. Use one per client or per product line.
The scope lives in the URL (/p/<slug>/roster), not in a cookie: a link to a
conversation is then unambiguous, two projects can be open in two tabs, and
there is no hidden "current project" to fall out of sync with what is on screen.
Switching projects keeps you on the same tab, because comparing two inboxes is
the usual reason to switch at all.
Isolation is enforced server-side on every query, not just in the UI:
transfer_to_agent can only reach colleagues in the same project — checked in
the executor, not merely omitted from the prompt, since a model can name an id
it was never givenProjects live inside an organisation, and an organisation is the security and billing boundary between different customers of yours.
Membership row with a role. Owners manage people and
billing; admins also manage agents, integrations and trust settings;
members use agents and review approvals. requireRole() in
lib/organizations.ts is the gate, and an organisation always keeps at least
one owner.Organization page): an owner or admin invites an email
with a role; the link works for seven days, for that address only, once.
It is emailed when the deployment has RESEND_API_KEY + EMAIL_FROM, and
shown to the inviter to copy either way.currentUser() in lib/auth.ts is the one check.Plans (lib/billing/plans.ts) are priced around what actually costs money:
published agents, and runs and conversations per month, with a monthly model
budget in dollars and a rate (runs per hour, client messages per minute). The
month-to-date numbers come from the same counters Insights reads.
Limits are enforced on the server, where the cost is incurred - not in the
UI: publishing an agent (402), starting a run from any trigger (a manual run
answers 402, a webhook 429, a schedule records action_item.refused in the
audit log and consumes the tick), and a client message on a public agent
(the client sees "temporarily unavailable"; the owner sees why in the audit
log, once an hour at most). The Inngest runner also caps concurrent runs per
organisation so one tenant's fifty schedules share the runtime.
Stripe provides the money side: Checkout for a paid plan, the hosted portal
for cards and invoices, and a webhook (/api/billing/webhook) that mirrors
the subscription onto the organisation - a request never asks Stripe what
plan someone is on. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and the
two STRIPE_PRICE_* ids.
With no Stripe key the deployment is self-hosted and has no limits: there
is nothing to upgrade to, so nothing is capped. ENFORCE_PLAN_LIMITS=true
enforces the free tier anyway (the test suites use it).
Integration secrets - webhook URLs and signing secrets, email API keys - are
sealed with AES-256-GCM under VAULT_KEY before they reach the database
(lib/vault.ts) and opened only inside lib/work/integrations.ts at the
moment of delivery. Integration.config holds only what is safe to display
(a host, a from-address). Nothing writes a secret to a log or an audit row;
a row from before the vault existed is sealed on its first use. Rotating the
key means reconnecting integrations, which is the right failure mode for a key
that guards other people's credentials.
Two required foreign keys were added after the first release
(Agent.projectId, then Project.organizationId), and db push alone cannot
add a NOT NULL column to rows that already exist. scripts/backfill-projects.mjs
runs first (wired into npm run db:push and the migrate container), on both
Postgres and SQLite: it creates the missing tables, ensures a default project
and a default organisation, assigns existing rows to them, and makes every
pre-existing user an owner of that organisation - because before tenancy they
all shared one workspace. On a fresh database every step is a no-op.
An agent with a scope of work does things when nobody is talking to it.
The scope is a standing brief per agent - context, objectives, the documents
it may search - plus a trigger: a cadence ("every Monday at 09:00,
Asia/Bangkok"), an inbound webhook (POST /api/hooks/<token> with any JSON),
or only by hand. The wizard's third step and the editor's Scope of work
panel edit the same record.
Context is asked, not demanded. Instead of one blank textarea, an agent's
context is four short questions - what it should know about the work, who it
is working for, what a good week looks like, and anything it must never do -
each with an example in its placeholder rather than prefilled text somebody
has to delete first. The answers are stored as ScopeOfWork.contextAnswers
and composed into the same single context string the prompt and retrieval
have always read, so this is an authoring change and nothing downstream knows
about it (lib/work/context.ts). A scope written before the questions existed
opens with its text as the first answer.
Form by default, a picture when you want one. The editor stays a guided
form - identity, behaviour, model and context are a handful of independent
settings, and a form is what those are for. The scope of work is the one part
that is genuinely a pipeline, so it has a Flow toggle in the panel header:
the same record drawn as a small diagram - the trigger, the agent, one node
per allowed tool, an approval gate when any of those tools can reach the
outside world, and where the result lands. Clicking a node opens the same
control the form view has for that setting, bound to the same state, so the
two views cannot disagree; the nodes themselves cannot be added, removed or
rewired, because the shape comes from the configuration rather than the other
way round (lib/work/flow.ts derives the nodes, components/builder/ scope-flow.tsx draws them). Every agent opens in the form view.
Two ways to get to an answer faster:
lib/work/context-draft.ts). It only
fills questions that are still empty, never overwrites what a person typed,
and leaves a question out entirely when the documents do not support an
answer - a missing answer beats a plausible one, since these become standing
instructions. Nothing is saved on the owner's behalf.A run is an action item moving through a fixed state machine:
queued -> in_progress -> done | failed | needs_approval
needs_approval -> approved -> executing_external -> done | failed
needs_approval -> rejected
lib/work/runner.ts owns the loop: it hands the model the tool set and
alternates model turns with tool calls until the model stops or the
iteration cap is hit. Every turn and every tool call is a durable Inngest
step, so a crash or a platform timeout resumes from the last completed step
instead of re-running (and re-billing) the whole task. The final assistant
message is the run's report; findings, draft ids and every tool call are
kept on the item.
The tool set (lib/work/tools.ts) is small and closed, and each tool declares
a risk level that the runner gates on:
| Tool | Risk | What it does |
|---|---|---|
search_documents | read | Search over the agent's uploaded documents (limited to the scope's linked ones). Company Context is injected, never searched |
web_research | read | Search (Brave, or Tavily), read the top pages, summarise with numbered sources; saved to the item. Results are cached by query for 6 hours across organisations; each call is metered per organisation |
draft_content | draft | Writes a blog post, social caption or email into a Draft. Never publishes |
schedule_followup | internal | Queues the next task as its own action item, now or after a delay, with this run's report as context |
escalate_to_human | internal | Flags the run for a person - an issue in the Inbox - when the escalation rule applies or the work cannot be done safely |
publish_post | external | Sends a draft to the organisation's publishing webhook |
send_email | external | Sends through Resend |
When a run finishes, one more short model call turns its report into the
account a person actually reads: three or four sentences in plain language -
what it did, what it found, and what it thinks should happen next - plus a
one-line headline for list rows. Both are kept: the plain-language version is
ActionItem.summary and is what every surface shows first; the agent's own
Markdown report stays underneath it in result.summary, one disclosure away.
The call is its own durable step after the work is on the record, so losing it
costs a summary and never the task, and an install with no provider key falls
back to a summary composed from the record itself (lib/work/summary.ts).
Every agent starts in draft-only mode: external tools do not reach their
integration. The call is recorded as the item's pendingAction, the run ends
in needs_approval, and nothing goes out until a person approves it under
Work. Approval releases exactly that one action; rejection closes the item.
Moving an agent to auto mode is a field on the scope (autonomy), exposed in
the UI once the oversight layer exists.
Integrations live per organisation under Integrations: a generic webhook
(point Zapier, Make, or your own endpoint at it; deliveries are signed with
X-Desker-Signature when a secret is set) and a Resend email connector. Their
config is stored in plain text for now and is never returned to the browser,
logged, or written to an audit row; the credential vault replaces the store.
Nothing an agent does publicly happens without a visible, reviewable trail.
needs_approval with the
post or email shown in full. An owner can approve, edit the draft and then
approve, or reject with a reason; each decision is an audit row. The
Approvals tab and the Work page share one card.lib/work/digest.ts).publish_post and send_email, so posts
can flow while emails still wait. Every new agent starts draft-only.escalate_to_human, which creates an
issue and marks the run. Nothing is keyword-matched./p/<slug>/audit, GET /api/audit): every tool call an
agent makes - in chat or at work - with its inputs, its result, and what
triggered it, alongside every approval, rejection, draft edit, trust change
and integration change. Filter by agent, actor, action and date; export the
same rows as CSV. Rows are never edited or deleted.The product speaks to owners, not to engineers, and three surfaces used to speak in implementation detail. Each has a small pure module behind it so the copy can be tested and cannot drift between two views:
lib/insight-copy.ts): every figure carries a caption that
says what it is, which way it moved against the previous period, and what to
do about it - "42 client conversations this week · up 14% · 90% finished
without needing a person". Token counts are gone from the page: the lead
numbers are an estimated time saved and an estimated cost, with cost per
task and conversation rather than per million tokens. Tables say "Tasks",
"Finished", "Waiting on you"; the raw token figures live behind View
technical details for whoever is checking an invoice.lib/audit-copy.ts): a day-by-day timeline of sentences -
"Researched competitor pricing — 9:03am", "Queued a post for your approval —
9:15am" - with the verb, the target and the payload behind View technical
details on every row. A unit test walks every action the product writes and
fails if one of them renders as a raw dotted verb or mentions a tool id.lib/work/integration-health.ts): three states -
Connected, Needs attention, Disconnected - each with the consequence
rather than a status code: "The last attempt came back an error: Endpoint
answered 410. Anything an agent sends will fail until it works again." The
verdict is read from the audit trail, so nothing extra is stored or polled.Alongside that, a sweep of the field labels, helper text and error messages across the hire wizard, the scope of work, billing and integration setup: the label says what the field is, one sentence underneath says why it matters or gives an example, and every validation message says what went wrong and what to do instead of restating a constraint.
Six short how-tos - hiring an agent, writing context, giving an agent work,
approvals, Insights, integrations - live once in content/guides/*.ts as
markdown, and are read in two places: the help panel inside the app and the
public /guides pages. One source, two readers, so nothing is written twice
and the two cannot drift apart. (They are TypeScript modules holding markdown
rather than loose .md files, so the client-side help panel can import the
same content the static pages do, with no filesystem read and no build step.)
searchGuides in lib/guides.ts) and opens a hit at the
heading it matched. ? anywhere outside a text field opens it too.HELP_TOPICS, and a unit test fails if a renamed
heading leaves one pointing at nothing./guides is public and ungated, in the sitemap, one static page per
guide. components/markdown.tsx renders the fixed subset the guides are
written in; there is no HTML passthrough.Everything is built from tokens defined once in app/globals.css and
documented with live examples at /design-system (signed in): one accent
and a few neutrals with three semantic tones, a five-step type scale plus a
display size for the marketing hero, an 8px spacing grid, four radii and
three shadows. Tailwind's default type, radius and shadow steps are reset, so
an unlisted size is an absence rather than a quiet exception, and
npm run check:contrast verifies every colour pair in both themes; the
design-system page computes the same ratios live for whichever theme is on.
The usability rules the screens follow: every action answers (a toast for
publish, approve, reject, run, save, invite); destructive actions confirm and
offer undo (unpublish, reject); fields that take plain language show a live
reading and clickable examples (escalation rule, objectives, context); one
StatusBadge for every status anywhere; one ListRow for every list item;
one primary action per screen; the editor is sectioned so editing is one
decision at a time with the company-context Q&A pane alongside.
The one screen allowed to be a picture. Its tokens sit beside the product's
in globals.css (--sky-*, the hero and title type sizes, the serif
loaded through next/font) and are documented on /design-system under
"Landing sky". Three rules keep it honest:
components/marketing/reveal.tsx swaps in Motion only after hydration,
only for elements still below the fold, and never under
prefers-reduced-motion. An e2e loads the page with scripts off and asserts
nothing is hidden and the FAQ still opens.load (another e2e checks that), the browser Sentry SDK is
deferred the same way, and the sky, sun and glass button are CSS.hero-stage.tsx is a showreel of
five agents doing their jobs - support answering from a document, a
marketer drafting on a schedule, a researcher citing sources, developer
support reproducing a bug, an assistant moving a follow-up - with role tabs
and a progress bar, built from the app's own components so it cannot go
stale. The same scenes, finished, are the frames under each feature.Copy, features, FAQ and the calls to action all come from
content/landing.json; the icons are generated from the brand mark by
scripts/make-app-icons.mjs.
The unauthenticated pages - / (landing), /showcase (every agent role,
with an example of each at work), /contact, /terms, /privacy - live in
app/(marketing) and share the product's tokens and components.
Every word comes from /content. site.json (company, nav, footer),
landing.json, showcase.json, contact.json, and templates.json - the
role templates, which are both the hire wizard's first step (eight roles plus
"start from scratch", each pre-filling job, team, opening message,
personality, escalation rule, chat tools and work tools) and the showcase's
cards. lib/content.ts validates the files at build; a missing
field fails typecheck with the file and field named. A copy change is a text
edit and a redeploy. See content/README.md.
Page titles, descriptions and Open Graph tags come from the same files, the
OG preview image is generated from the landing headline, and sitemap.xml
and robots.txt are generated (the product routes are disallowed). The
contact form stores every message as feedback, forwards it to the
notification webhook, and emails CONTACT_EMAIL when Resend is configured.
lib/monitoring.ts; set SENTRY_DSN
and NEXT_PUBLIC_SENTRY_DSN to turn it on (nothing else imports Sentry).
Unhandled API errors, failed action items and Inngest functions that
exhaust their retries are reported with the organisation, agent and item
tagged. Request bodies are never sent.job-runtime-heartbeat), which alerts when a
check-in is missed, and GET /api/health/jobs answers 503 once two
intervals pass without a heartbeat - point an uptime monitor at that URL.
A failed run also raises an inbox issue and posts to
NOTIFY_WEBHOOK_URLS. A silently dead schedule is the one failure this
product must never have.Feedback
row, forwards it to the notification webhook, and counts it. Page views
and key actions (publish, approve, reject, manual run) are recorded as
ProductEvent rows - first-party, no third party - so what the first
customers actually use decides what gets built next./terms and /privacy are template text (replace them; bump
TERMS_VERSION in lib/legal.ts when you do). Sign-up requires the
consent checkbox and records termsAcceptedAt + termsVersion on the user.desker-staging, deploying the
staging branch to https://desker-staging.vercel.app with its own Neon
database (desker_staging), its own auth and vault secrets, and its own
Inngest app id. A banner marks it. Seed it with
DEMO_PASSWORD=... node scripts/seed-demo.mjs https://desker-staging.vercel.app,
which builds a demo organisation through the real API: two published
agents, a context document, a weekly scope of work and one run. Promote
by merging main into staging.npm run check:security (after next build) scans
the client bundle for server secret names and values, client components
for server env reads, and - with - every integration for
unsealed secrets.Work that must happen without a browser tab open runs as
Inngest functions, registered in lib/jobs/ and
served from /api/inngest. Nothing else talks to a scheduler or a queue.
heartbeat - a cron that writes a Heartbeat row every minute in
development (every five in production) and prunes rows older than a day.
/api/health reports the newest tick under jobs, with alive: false once
two intervals have passed without one. Point an uptime monitor at that field
specifically - an agent that silently never runs is the worst failure this
product can have.scope-scheduler - every minute, claims every cron scope whose latest fire
time has not run (a conditional update, so overlapping ticks or instances
can never both claim one) and fans them out as work/scope.due events. Its
own work stays one query plus one small write per due scope, however many
agents share "weekdays at 9am". A missed minute is caught up on the next
tick; an outage never replays every tick it missed.scope-fire - turns one claimed tick into a run, 25 at a time, retry-safe
through ActionItem.dedupeKey.action-item-run - executes one action item as durable steps, waiting
first if it is a follow-up scheduled for later. Capped at
WORK_MAX_CONCURRENT_RUNS across the platform and 3 per organisation, and
throttled to ORG_RUNS_PER_MINUTE starts per organisation: a tenant with
fifty 9am agents queues behind itself, not in front of everyone else.work-watchdog - every five minutes: tells an owner once when their run
passes RUN_TIME_BUDGET_MS, ends a run stuck at 4x the budget as a visible
failure, and raises a platform alert (Sentry, plus OPS_ALERT_WEBHOOK_URL)
for a queue backlog, a spike in failed runs, a spike in escalations, or many
runs over budget at once - each at most once an hour. /api/health/jobs
also reports queue depth and turns 503 while work is backed up.action-item-execute-approved - sends what a person approved.digest-scheduler - every fifteen minutes, finds agents whose digest
cadence has come round in their own timezone since their last one and writes
exactly one digest each. Quarter-hourly rather than minutely because a
digest is due at a morning hour, and a few minutes of slack on "8am Monday"
is invisible to a reader.digest-generate - one agent's digest on demand, behind Send one now.Locally, INNGEST_DEV=1 and npm run inngest:dev. In the compose stack an
inngest service does the same. In production, leave INNGEST_DEV unset and
set INNGEST_SIGNING_KEY and INNGEST_EVENT_KEY from Inngest Cloud; the same
route serves both, and it registers itself under NEXT_PUBLIC_APP_URL.
Every model call - a chat turn, a summary, later an autonomous task - carries a
billing context (organizationId, optional agentId) that the provider seam
requires rather than accepts; a call with no tenant to bill does not typecheck.
After each API request the provider increments a UsageCounter row keyed by
organisation, UTC month, provider, model and agent, so a month total per
organisation and a per-agent breakdown come from the same table
(usageForOrganization() in lib/usage.ts). Web research is metered in the
same table under provider search (searches and pages read), so a plan quota
can cover both. Recording never fails a reply.
AuditLog records who did what to which thing: actor (user | agent | system | schedule), a dotted action, a target, and metadata that must never contain a
secret. audit() in lib/audit.ts writes it and never throws. Sign-up and
project changes are recorded now; tool calls join them when agents start
acting on their own.
Every tool call an agent makes during a run is an AuditLog row
(tool.called, with the tool, its risk level, trimmed inputs and whether it
was gated), alongside action_item.* events for creation, completion,
approval and rejection.
One Next.js deployment. No microservices, no separate API server, no queue.
src/
app/(admin)/p/[project]/ Admin dashboard — roster, inbox, insights, builder
app/(auth)/… Sign in / sign up
app/c/[agentId] Public client chat page
app/embed/[agentId] The document inside the widget iframe
app/api/… Route handlers (agents, chat, documents, issues, events)
lib/llm/provider.ts Provider-neutral chat seam (streamChat -> ChatEvent)
lib/llm/anthropic.ts Claude implementation
lib/llm/openai.ts OpenAI implementation, to keep the seam honest
lib/agent-runtime.ts One conversation turn: prompt, tools, streaming, persistence
lib/agent-prompt.ts System prompt assembly (pure, unit tested)
lib/tools/ The fixed four-tool set and its executor
lib/rag/ Chunking, embeddings, hybrid retrieval
lib/storage.ts File storage interface (local disk; S3 driver stubbed)
lib/notify.ts Outbound webhooks for things that need a person
lib/summarize.ts Rolling summaries + returning-client recall
lib/conversation.ts Resolving which agent is answering after a transfer
public/embed.js The widget loader third-party sites include
public/brand/ Logo masks, generated from the source artwork
lib/llm/provider.ts defines streamChat(request): AsyncIterable<ChatEvent>.
Both providers own their own agentic loop — the vendors' loop mechanics differ
enough that pretending otherwise would leak — and emit the same neutral events.
Switching an agent from Claude to GPT is one field, Agent.modelProvider.
The OpenAI implementation exists specifically so the abstraction cannot quietly become an Anthropic-shaped interface. It is not at feature parity, on purpose.
Hybrid: a keyword ranking fused with a vector ranking via reciprocal rank fusion. Vector-only retrieval misses exact identifiers (SKUs, error codes, policy numbers); keyword-only retrieval misses paraphrase.
tsvector/ts_rank for keywords.Nearest-neighbour search always returns something, so a chunk is only returned if it matched on keywords or cleared a cosine floor. Ask a published agent about the capital of France and it gets nothing back, and says so.
Two embedding backends:
EMBEDDING_PROVIDER | What it does |
|---|---|
local (default) | Deterministic hashed bag-of-words. No key, no network, no model download. Captures lexical overlap, not meaning — relevance is carried by the keyword half. |
openai | text-embedding-3-small. Real paraphrase and synonym recall. Needs OPENAI_API_KEY. |
Anthropic does not serve an embeddings endpoint, which is why local is the
default: a Claude-only deployment should not need a second vendor to do RAG at
all. Set openai when recall matters more than dependency count.
An escalation used to be a dead end: the agent could raise a hand, but nobody could reply, so the client got "I've passed this to a colleague" and then silence. A colleague can now type into the transcript from the inbox and it lands in the client's chat window, attributed to them by name.
Replying takes the conversation over — Conversation.replyMode flips to
human, and the agent stops answering until it is handed back. The client's
messages are still recorded and still reach the inbox; they just are not
answered by an AI while a person is holding the conversation.
Delivery is an SSE feed that tails the database, not the in-process event bus. The bus is faster, but the production bundle emits a copy of the module into each route's chunk, so a publisher in one route reached no subscriber in another — replies silently never arrived, with no error anywhere. Reading committed rows is immune to that, survives more than one instance, and is the same query a reload runs, so the live path and the reload path cannot disagree.
NOTIFY_WEBHOOK_URLS (comma-separated) receives a Slack-shaped payload when a
conversation escalates or a high or critical issue is logged, with a deep
link to the conversation. Lower severities are deliberately silent — logging
every cosmetic bug to a chat channel is how notifications get muted. Delivery is
best-effort with a 5s timeout: a broken webhook must never break a client's chat.
A rolling summary is regenerated in the background once enough has been said to change it (four messages), which keeps the inbox scannable without opening every transcript. The same summaries are handed to an agent as recall when that client returns, scoped to their session id — so a returning client is not a stranger. Throttling is the design constraint: a summary per turn would roughly double model spend.
Every reply — from the agent or from a colleague — carries thumbs up/down for the client. A rating is accepted only for a message inside that session's own conversation. Insights shows satisfaction per agent and lists the replies clients marked unhelpful with the question that prompted each, which is the most direct list of what to fix after the content gaps.
transfer_to_agent lets a receptionist hand a client to the right specialist.
Conversation.agentId never moves — it is half the unique key and the client's
chat link points at it — so activeAgentId changes instead, and the client keeps
the same window and the same history.
Fixed at five. Admins choose which an agent may use; the permission is enforced server-side, not just by omitting the definition, because a model can name a tool it was never given.
| Tool | Effect |
|---|---|
search_documents | Hybrid search over that agent's uploaded documents; offered only when it has some. Company Context is in every prompt, not searched |
log_issue | Creates an Issue (with severity) on the conversation |
log_suggestion | Creates a suggestion |
escalate_to_human | Flags the conversation, files an escalation, and notifies |
transfer_to_agent | Hands the conversation to another published agent |
Issues, suggestions and escalations appear on the dashboard the moment the tool call commits, over an SSE feed, with polling as a backstop.
public/embed.js is dependency-free, sets every style inline on its own two
elements, and puts the chat in an iframe — so a host page's CSS cannot break it,
it cannot leak styles into the host page, and the host page's scripts cannot
read the conversation. It is a floating panel on desktop and a full-screen sheet
under 640px, keyboard operable, and it closes on Escape from either side of the
frame boundary.
/widget-demo.html?agent=AGENT_ID is a plain HTML page for trying it against
something that knows nothing about Desker.
npm run dev # dev server
npm run verify # typecheck + lint + contrast audit + unit tests
npm test # unit tests (no key, no database)
npm run test:integration # live model + database (needs ANTHROPIC_API_KEY)
npm run test:e2e # Playwright, desktop + mobile viewports
npm run check:contrast # WCAG audit of the design tokens
npm run db:studio # browse the database
The logo is a single-colour mask (public/brand/*.png), painted by CSS with
the --accent design token rather than shipped as a light and a dark export.
One asset therefore renders correctly in both themes and the two can never drift
apart. Regenerate from the source artwork after changing it:
node scripts/make-brand-assets.mjs # logo masks
The brand colour is the sticky-note studio's ink blue, #3558E6
(--brand, oklch(0.527 0.218 267.4)), mirrored in src/lib/brand.ts for the
widget launcher and the app icon. It scores 5.4:1 on the paper; every pair is
checked by npm run check:contrast.
Agents get a generated duotone face — an offset colour field with two eyes and
a mouth, in the "beam" idiom modern products settled on for generated avatars —
drawn as inline SVG in src/components/agent-figure.tsx. Every parameter is
derived from a seed, so an agent with no chosen face gets a stable one from its
id; the picker offers twelve looks per tone, stored as a short
beam:<variant>:<tone> key. The artwork follows the theme, stays crisp at any
size, and costs nothing to store.
The face is drawn in the tile's ground colour on the field colour, a pair that
is contrast-checked at 3:1 or better in both themes by npm run check:contrast.
Uploaded photos still take precedence.
package-lock.json is resolved on Linux, not on a developer's macOS
machine. npm records only the optional platform variants it actually resolves,
and a macOS-resolved tree omits transitive dependencies of the wasm fallbacks
that npm selects on Linux — so npm ci inside the Docker build fails with
Missing: @emnapi/... from lock file. After changing dependencies, regenerate
it the same way:
docker run --rm -v "$PWD":/w -w /w node:22-slim \
npm install --package-lock-only --no-audit --no-fund
npm install on macOS afterwards is fine; it adds the darwin binaries without
dropping the Linux ones.
npm run test:load fires 100 organisations x 3 scheduled agents in one tick,
each doing seven tool calls against a scripted model with realistic latency,
through the real claim, fan-out and runner under production's limits - and
reports claim time, run-start latency, queue depth and drain time. It needs a
database of its own (a running app's scheduler would fire the test's
schedules): for Postgres,
docker run -d --rm --name desker-load-pg -e POSTGRES_PASSWORD=load -e POSTGRES_DB=desker_load -p 55432:5432 pgvector/pgvector:pg16
export LOAD_DATABASE_URL=postgresql://postgres:load@localhost:55432/desker_load
DATABASE_PROVIDER=postgresql DATABASE_URL=$LOAD_DATABASE_URL npm run db:push
DATABASE_PROVIDER=postgresql npm run test:load
LOAD_ORGS, LOAD_AGENTS_PER_ORG and LOAD_MODEL_LATENCY_MS resize it.
Regenerate the client for your usual provider afterwards (npm run db:generate).
prisma/schema.prisma is generated from prisma/schema.template.prisma so one
model set can target either engine — the pgvector column simply does not exist
on SQLite.
DATABASE_PROVIDER=sqlite npm run db:push
DATABASE_PROVIDER=postgresql npm run db:push
Never edit prisma/schema.prisma directly; edit the template.
tests/unit) — chunking, prompt assembly, BM25 and rank fusion,
tool-call validation and permissions, rate limiting. No key needed.tests/integration) — against a real database.
tenancy.test.ts needs only DATABASE_URL: organisations, the usage counter
(including concurrent first writes to a new bucket) and the audit log.
agent-turn.test.ts is a real streamed turn against a live Claude key that
exercises retrieval and each tool, and asserts on the rows they leave
behind; it skips itself with a warning when no key is present.tests/e2e) — signup, the wizard, upload and indexing, publish, the
public link, the cross-origin widget, keyboard navigation, dark mode, and a
360px viewport. Boots the production standalone build, not the dev server.Targeting WCAG 2.1 AA.
npm run check:contrast parses the design tokens out of globals.css,
converts oklch to sRGB, and checks every foreground/background pair the UI
actually renders against its threshold in both themes. It exits non-zero
on a failure and runs in CI.Field component that wires up the label,
hint, and error with the right ARIA attributes, so a field cannot ship without
an accessible name.prefers-reduced-motion respected.Automated checks do not replace a manual pass with a screen reader.
The Docker image is the portable artifact — any container host runs it. It is
built on node:22-slim rather than Alpine: Prisma's query engine wants
glibc + openssl, and musl pulls in the wasm fallbacks described above.
Vercel runs vercel-build when it is present, and here it is
npm run deploy:build: generate the Postgres schema and client, run the
backfill, apply the schema, then next build. The schema step
(scripts/db-deploy.mjs) diffs the live database against the schema first
and refuses if the resulting SQL drops a table or a column - Prisma's own
guard would also refuse every new unique index, which blocked deploys twice.
A deliberate destructive change ships with ALLOW_DESTRUCTIVE_MIGRATION=1.
DATABASE_URL, DATABASE_PROVIDER=postgresql, ANTHROPIC_API_KEY,
AUTH_SECRET, AUTH_TRUST_HOST=true, NEXT_PUBLIC_APP_URL (the deployment's
public origin), and INNGEST_SIGNING_KEY + INNGEST_EVENT_KEY from an
Inngest Cloud app.GET /api/health must return ok: true with database: "up".https://<your-domain>/api/inngest. Within
a few minutes /api/health reports jobs.alive: true.Uploaded documents use the local-disk storage driver (STORAGE_DIR), which is
ephemeral on Vercel; swap the driver in lib/storage.ts for object storage
before relying on uploads there.
fly launch --no-deploy # generates fly.toml from the Dockerfile
fly postgres create --name roster-db # or bring your own Postgres
fly postgres attach roster-db # sets DATABASE_URL
fly secrets set \
ANTHROPIC_API_KEY=sk-ant-... \
AUTH_SECRET="$(openssl rand -base64 32)" \
DATABASE_PROVIDER=postgresql \
NEXT_PUBLIC_APP_URL=https://your-app.fly.dev
fly volumes create roster_storage --size 1 # uploaded documents
fly deploy
Add to fly.toml so uploaded documents survive a deploy:
[[mounts]]
source = "roster_storage"
destination = "/data/storage"
pgvector: run CREATE EXTENSION IF NOT EXISTS vector; once against the
database before the first deploy.
Beyond one instance: the SSE dashboard feed and the rate limiter are both in-process. The dashboard degrades to its polling interval; the rate limit becomes per-instance. Move both to Redis before scaling out.
Storage: the local-disk driver needs a persistent volume. For multiple
instances, implement the S3 driver in lib/storage.ts — the interface is
already the only thing the rest of the app talks to.
Billing, invites and roles, voice channels, native mobile apps, an agent marketplace, fine-tuning, and a drag-and-drop workflow builder. Agents call a fixed set of tools - five in chat, six at work; letting admins define arbitrary tools, or run code, is a different product.
lib/pricing.ts
(first-party Anthropic list prices; MODEL_PRICING_JSON overrides or adds
models) applied to the usage counters. An unpriced model shows as unknown,
never as free - the figure is what billing will meter.DATABASE_URL