Gapless

Backend and API

The backend services of the assistant, what each one owns, how they talk to each other, and where data is stored.

The backend turns a user's message into a safe, permission-checked, logged, and streamed answer. It also runs everything that happens in the background: ingestion, syncs, evals, and cleanup. This page maps the services and their responsibilities. The other pages in this section cover the API contract and the cross-cutting concerns every service must get right.

The design

As recommended in Key decisions, most logic lives in one backend application split into modules. A few parts run separately because they scale differently or hold powerful credentials.

Services and modules

PartRuns asOwnsWhy it is separate or not
API gatewayManaged gateway or a thin proxyTLS, auth token validation, rate limits, routingRejects bad traffic before it costs anything
Chat module (orchestrator)Module in the backend appRunning a chat turn: history, guardrails, retrieval, prompt, model call, tools, savingCore logic, changes often, benefits from being easy to debug
Conversations moduleModuleListing, renaming, deleting, sharing conversationsSimple CRUD
Documents moduleModuleUploads, document lists, ingestion job statusCreates jobs, does not do heavy work itself
Admin moduleModuleTenant settings, connectors, usage reports, user rolesAdmin-only endpoints with stricter checks
Feedback moduleModuleRatings, reasons, flags for reviewFeeds evaluation
Model gatewaySeparate service or libraryProvider adapters, retries, fallback, token counting, cost trackingShared by online and batch paths. A service if several apps use it
Retrieval serviceSeparate serviceHybrid search, permission filters, re-rankingDifferent scaling and latency profile. Often colocated with the index
Tool serviceSeparate serviceExecuting actions in other systemsHolds credentials for other systems. Separate security boundary
WorkersSeparate deploymentsIngestion, connector syncs, eval runs, cleanupLong-running, CPU and memory heavy, must not slow the API

Data stores

StoreHoldsNotes
PostgresTenants, users, conversations, messages, sources used, feedback, jobs, settings, audit eventsUse row-level security by tenant. See Multi-tenancy
RedisRate limit counters, short-lived caches, stream replay buffers, locksEverything here can be lost without data loss
Object storageUploaded files, parsed text, exportsPrefix by tenant. Encrypt at rest. Lifecycle rules for deletion
Search indexChunks, embeddings, metadata, ACLsRebuildable from source documents and object storage

A useful test for every store: if it were wiped, could you rebuild it? Postgres cannot be rebuilt, so it needs backups and point-in-time recovery. The search index can be rebuilt from sources, but it takes hours to days, so it still needs a recovery plan. Redis can be lost. See Disaster recovery.

The chat turn in code

The orchestrator's core is a single function that reads like the request lifecycle. Keep it this readable. If it grows past a few hundred lines, extract steps into functions, but keep the top-level sequence in one place.

export async function runChatTurn(ctx: RequestContext, input: ChatInput, stream: EventStream) {
  const conversation = await conversations.load(ctx, input.conversationId);
  const history = trimHistory(conversation.messages, CONTEXT_BUDGET.history);

  const inputCheck = await guardrails.checkInput(ctx, input.text);
  if (!inputCheck.allowed) return stream.error('input_blocked', inputCheck.reason);

  const query = history.length ? await rewriteQuery(ctx, history, input.text) : input.text;
  stream.status('Searching documents');
  const chunks = await retrieval.search(ctx, query, { topK: 8 });
  stream.sources(chunks);

  const prompt = prompts.render('answer_v14', { chunks, history, question: input.text, ctx });
  const result = await modelGateway.stream(ctx, { model: 'primary', prompt }, (token) => stream.token(token));

  const outputCheck = await guardrails.checkOutput(ctx, result.text, chunks);
  if (!outputCheck.allowed) return stream.error('output_blocked', outputCheck.reason);

  const saved = await conversations.saveTurn(ctx, {
    input, output: result, chunkIds: chunks.map((c) => c.id), promptVersion: 'answer_v14',
  });
  stream.done({ messageId: saved.id, usage: result.usage });
}

RequestContext carries the user ID, tenant ID, permission groups, trace ID, and a cancellation signal. Every function takes it. That single habit makes tenant isolation, tracing, and cancellation work everywhere without extra effort.

Pages in this section

PageCovers
Authentication and authorizationSSO, sessions, roles, service identities, acting on behalf of users in other systems
API designResources, endpoints, idempotency, pagination, versioning
Rate limits and quotasRequest and token limits, algorithms, headers, cost estimation
ReliabilityTimeouts, retries, fallbacks, circuit breakers, cancellation
ErrorsError format and the full error code reference

On this page