DEV Community

Sonam Gupta
Sonam Gupta

Posted on

Build a Self-Refreshing Company Handbook Assistant with TypeScript

A company handbook assistant is easy to prototype.

Embed a document, run a similarity search, pass the closest result to an LLM, and return the answer.

The harder questions appear after the demo:

  • Who checks whether the asynchronous embedding job finished?
  • What happens if the worker restarts during indexing?
  • How does the index stay current when the handbook changes?
  • Where do you record the answers employees received?
  • How do you stop the model from answering when retrieval finds nothing relevant?

The policy-recall-line sample handles that entire lifecycle with one durable Stateful Actor per company.

It crawls and embeds a handbook URL, polls the asynchronous indexing job, retries failures, refreshes the source every 24 hours, answers grounded questions, and stores every exchange in an embedded SQL ledger.

No separate vector database, task queue, cron server, or state database is required.

What we are building

The application exposes these primary routes:

POST /company/{company}/source
POST /company/{company}/ask
POST /company/{company}/rpc/audit
POST /company/{company}/rpc/reindexNow
GET  /health
Enter fullscreen mode Exit fullscreen mode

Each company gets its own PolicyLine actor.

That actor owns:

  • The registered handbook URL
  • The Telnyx Storage bucket containing its embeddings
  • Current indexing status
  • Retry counters
  • Scheduled polling and reindexing tasks
  • A SQL ledger containing embedding jobs and policy questions

The architecture looks like this:

Employee question
      |
      v
Edge Compute fetch handler
      |
      | company slug
      v
PolicyLine Stateful Actor
      |
      +--> Telnyx similarity search
      |
      +--> Telnyx-hosted LLM
      |
      +--> Embedded SQL audit ledger
Enter fullscreen mode Exit fullscreen mode

The actor is not only the request handler. It is the lifecycle owner for that company’s policy knowledge.

Route every company to one actor

The public fetch handler extracts the company slug from the URL:

const match = path.match(/^\/company\/([^/]+)(\/.*)?$/);

const company = match[1];
const rest = match[2] ?? "/";
Enter fullscreen mode Exit fullscreen mode

It then derives a deterministic actor ID:

const stub = env.LIB.idFromName(`company:${company}`);
Enter fullscreen mode Exit fullscreen mode

Calling:

/company/acme/ask
Enter fullscreen mode Exit fullscreen mode

always reaches the actor named:

company:acme
Enter fullscreen mode Exit fullscreen mode

A request for another company reaches a different actor with separate state and SQL storage.

The remaining path is passed to the selected actor:

const innerReq = new Request(`https://internal${rest}`, {
  method: req.method,
  headers: req.headers,
  body: req.body,
});

return stub.fetch(innerReq);
Enter fullscreen mode Exit fullscreen mode

This keeps the outer router small. Company-specific behavior and data remain inside the actor that owns them.

Register and embed a handbook

A company registers its handbook through:

POST /company/acme/source
Enter fullscreen mode Exit fullscreen mode

Example request:

curl -X POST \
  https://your-worker.telnyxcompute.com/company/acme/source \
  -H "Content-Type: application/json" \
  -d '{
    "company": "acme",
    "url": "https://acme.example.com/handbook"
  }'
Enter fullscreen mode Exit fullscreen mode

The actor first checks whether that company already has a registered source:

const slug = `company:${company}`;
const state = await this.getState();

if (state.sources[slug]) {
  return this.json(
    { error: "Source already registered" },
    409,
  );
}
Enter fullscreen mode Exit fullscreen mode

It then starts an asynchronous embedding job using the Telnyx URL Embeddings API:

const res = await fetch(
  "https://api.telnyx.com/v2/ai/embeddings/url",
  {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${key}`,
    },
    body: JSON.stringify({
      url,
      bucket_name: bucket,
    }),
  },
);
Enter fullscreen mode Exit fullscreen mode

The endpoint crawls the URL and stores its embeddings in the configured Telnyx Storage bucket.

The response contains a task ID:

const data = await res.json();
const taskId = data.task_id;
Enter fullscreen mode Exit fullscreen mode

The actor records that task in its embedded SQL ledger:

INSERT OR REPLACE INTO embed_tasks (
  task_id,
  source_slug,
  started_at,
  finished_at,
  status
)
VALUES (?, ?, ?, NULL, 'processing')
Enter fullscreen mode Exit fullscreen mode

It also saves the source in durable actor state:

state.sources[slug] = {
  url,
  bucket,
  taskId,
  status: "indexing",
  lastIndexedAt: null,
  retries: 0,
};

await this.setState({ sources: state.sources });
Enter fullscreen mode Exit fullscreen mode

The registration endpoint returns immediately:

{
  "slug": "company:acme",
  "taskId": "task_123",
  "status": "indexing"
}
Enter fullscreen mode Exit fullscreen mode

Poll the asynchronous job without a separate queue

The embedding job does not finish during the registration request, so the actor schedules a polling task:

await this.schedule(
  POLL_INTERVAL_S,
  "onPollTask",
  { taskId, slug },
  { id: `poll:${taskId}` },
);
Enter fullscreen mode Exit fullscreen mode

POLL_INTERVAL_S is set to 60 seconds in the sample.

The stable ID is important:

poll:<taskId>
Enter fullscreen mode Exit fullscreen mode

If the task is still queued or processing, the actor schedules another check using that same ID:

await this.schedule(
  POLL_INTERVAL_S,
  "onPollTask",
  { taskId, slug },
  { id: `poll:${taskId}` },
);
Enter fullscreen mode Exit fullscreen mode

This makes scheduling an upsert rather than an ever-growing collection of duplicate timers.

The poll handler checks:

GET /v2/ai/embeddings/{taskId}
Enter fullscreen mode Exit fullscreen mode

If indexing succeeds, it updates both actor state and SQL:

source.status = "ready";
source.lastIndexedAt = new Date().toISOString();

await this.setState({ sources: state.sources });

this.ctx.storage.sql.exec(
  `UPDATE embed_tasks
   SET finished_at = ?, status = 'success'
   WHERE task_id = ?`,
  new Date().toISOString(),
  taskId,
);
Enter fullscreen mode Exit fullscreen mode

If the task fails, the actor schedules a retry after 600 seconds:

await this.schedule(
  RETRY_DELAY_S,
  "onRetryTask",
  { slug },
  {
    id: `retry:${slug}`,
    maxRetries: MAX_RETRIES,
  },
);
Enter fullscreen mode Exit fullscreen mode

The demo allows up to three retry attempts before marking the source as failed.

This is where the actor architecture earns its place. The indexing lifecycle is stored alongside the source that owns it, and scheduled tasks are not tied to one short-lived request handler.

Answer a policy question with retrieval

Once the source is ready, an employee can ask:

curl -X POST \
  https://your-worker.telnyxcompute.com/company/acme/ask \
  -H "Content-Type: application/json" \
  -d '{
    "company": "acme",
    "question": "What is the PTO carry-over cap?"
  }'
Enter fullscreen mode Exit fullscreen mode

If the source is still indexing, the actor returns 202:

{
  "error": "Source is still indexing. Try again shortly."
}
Enter fullscreen mode Exit fullscreen mode

When the source is ready, the actor sends the question to the similarity-search endpoint:

const res = await fetch(
  "https://api.telnyx.com/v2/ai/embeddings/similarity-search",
  {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${key}`,
    },
    body: JSON.stringify({
      bucket_name: bucket,
      query,
      num_of_docs: 3,
    }),
  },
);
Enter fullscreen mode Exit fullscreen mode

The application selects the result with the lowest distance:

export function selectTopHit(
  search: SearchHit[],
): SearchHit | null {
  const hits = search.filter(
    (hit) => hit && Number.isFinite(hit.distance),
  );

  if (hits.length === 0) return null;

  const best = hits.reduce((a, b) =>
    b.distance < a.distance ? b : a,
  );

  if (best.distance > SIMILARITY_FLOOR) {
    return null;
  }

  return best;
}
Enter fullscreen mode Exit fullscreen mode

The sample sets SIMILARITY_FLOOR to 0.9. A lower distance means a closer match.

If no result passes the threshold, the actor returns a controlled response:

I couldn't find a matching policy in the handbook.
Please contact HR for clarification.
Enter fullscreen mode Exit fullscreen mode

It does not ask the model to invent an answer.

The 0.9 threshold is a demonstration default, not a universal production setting. Test it against a representative set of questions and tune it for your content.

Compose the answer with Telnyx AI Inference

When retrieval finds an acceptable chunk, the actor passes only that excerpt and the employee’s question to the model:

const completion =
  await this.env.TELNYX.ai.openai.chat.createCompletion({
    model,
    max_tokens: 700,
    temperature: 0.2,
    messages: [
      {
        role: "system",
        content:
          "You are the company librarian. " +
          "Answer the employee's question using ONLY " +
          "the provided handbook excerpt. " +
          "Be concise and cite the excerpt.",
      },
      {
        role: "user",
        content:
          `Question: ${question}\n\n` +
          `Handbook excerpt:\n${chunk}`,
      },
    ],
  });
Enter fullscreen mode Exit fullscreen mode

The default model is:

moonshotai/Kimi-K3
Enter fullscreen mode Exit fullscreen mode

If the primary model fails or returns empty content, the sample retries with:

zai-org/GLM-5.3-Flash
Enter fullscreen mode Exit fullscreen mode

The inference client is supplied through the Telnyx Edge binding:

this.env.TELNYX.ai.openai.chat.createCompletion()
Enter fullscreen mode Exit fullscreen mode

The application does not place a separate inference provider key in its source code.

Record every answer in SQL

Whether retrieval succeeds or produces the controlled no-hit response, the exchange is written to qa_log:

this.ctx.storage.sql.exec(
  `INSERT INTO qa_log (
     asked_at,
     question,
     answer,
     top_distance,
     source_slug,
     answered
   )
   VALUES (?, ?, ?, ?, ?, ?)`,
  new Date().toISOString(),
  question,
  answer,
  topHit ? topHit.distance : null,
  slug,
  answered ? 1 : 0,
);
Enter fullscreen mode Exit fullscreen mode

The table becomes a lightweight audit ledger:

CREATE TABLE IF NOT EXISTS qa_log (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  asked_at     TEXT NOT NULL,
  question     TEXT NOT NULL,
  answer       TEXT NOT NULL,
  top_distance REAL,
  source_slug  TEXT NOT NULL,
  answered     INTEGER NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

You can retrieve the 20 most recent rows through:

curl -X POST \
  https://your-worker.telnyxcompute.com/company/acme/rpc/audit
Enter fullscreen mode Exit fullscreen mode

This makes questions such as “What did the assistant say about travel expenses?” answerable from structured records rather than scattered chat logs.

Refresh the handbook every night

A policy assistant becomes risky when the source changes but the index does not.

When the handbook is first registered, the actor schedules a second stable task:

await this.schedule(
  REINDEX_INTERVAL_S,
  "onReindexTask",
  { slug },
  { id: `reindex:${slug}` },
);
Enter fullscreen mode Exit fullscreen mode

REINDEX_INTERVAL_S is 86,400 seconds, or 24 hours.

When the task runs, it starts a fresh embedding job, resets the retry count, schedules polling for the new task, and re-arms the next refresh:

const taskId = await this.startEmbed(
  source.url,
  source.bucket,
  slug,
);

source.taskId = taskId;
source.status = "indexing";
source.retries = 0;

await this.setState({ sources: state.sources });
Enter fullscreen mode Exit fullscreen mode

During reindexing, /ask returns 202 because the source status is no longer ready.

The project also exposes an RPC for immediate reindexing:

curl -X POST \
  https://your-worker.telnyxcompute.com/company/acme/rpc/reindexNow \
  -H "Content-Type: application/json" \
  -d '{"slug": "company:acme"}'
Enter fullscreen mode Exit fullscreen mode

Run the project

Clone the code and install its dependencies:

git clone \
  https://github.com/team-telnyx/telnyx-code-examples.git

cd telnyx-code-examples/policy-recall-line
npm install
Enter fullscreen mode Exit fullscreen mode

Create your local configuration:

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

Set your Telnyx API key and create a Telnyx Storage bucket for the handbook.

Authenticate the Edge CLI:

telnyx-edge auth api-key set "$TELNYX_API_KEY"
Enter fullscreen mode Exit fullscreen mode

Before deployment, run the project checks:

npm run typecheck
npm run smoke
Enter fullscreen mode Exit fullscreen mode

Generate the Edge types and deploy:

telnyx-edge types
telnyx-edge ship
Enter fullscreen mode Exit fullscreen mode

Add the deployed worker URL to .env as BASE_URL, then register the configured handbook:

npm run bootstrap
Enter fullscreen mode Exit fullscreen mode

The full setup and API documentation are included in the repository.

Production considerations

This is a focused sample, so several controls should be added before using the pattern with real company policies:

  • Authenticate /source, /ask, /rpc/audit, and /rpc/reindexNow.
  • Authorize users against the requested company actor.
  • Validate handbook URLs before allowing the service to crawl them.
  • Tune the similarity threshold with an evaluation dataset.
  • Define retention and deletion policies for the Q&A ledger.
  • Add monitoring for sources that remain in indexing or failed.
  • Decide whether questions or answers may contain sensitive employee data.
  • Add citations that identify the source page or section more precisely.
  • Consider serving the existing index while a refresh is in progress.
  • Expand the source model if each company needs more than one handbook URL.

The current sample permits one registered source per company and returns 409 when that source already exists.

Why this is more than a RAG demo

The retrieval pipeline is only one part of the application.

The durable actor also owns:

  • Source registration
  • Asynchronous task state
  • Polling
  • Retry limits
  • Nightly refreshes
  • Inference fallback
  • The Q&A audit ledger

That is the difference between a document-question endpoint and a service that can keep operating after the first request finishes.

The model answers the question. The actor makes sure there is a current, searchable handbook available when the next question arrives.

Resources

Top comments (0)