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
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
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] ?? "/";
It then derives a deterministic actor ID:
const stub = env.LIB.idFromName(`company:${company}`);
Calling:
/company/acme/ask
always reaches the actor named:
company:acme
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);
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
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"
}'
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,
);
}
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,
}),
},
);
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;
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')
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 });
The registration endpoint returns immediately:
{
"slug": "company:acme",
"taskId": "task_123",
"status": "indexing"
}
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}` },
);
POLL_INTERVAL_S is set to 60 seconds in the sample.
The stable ID is important:
poll:<taskId>
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}` },
);
This makes scheduling an upsert rather than an ever-growing collection of duplicate timers.
The poll handler checks:
GET /v2/ai/embeddings/{taskId}
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,
);
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,
},
);
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?"
}'
If the source is still indexing, the actor returns 202:
{
"error": "Source is still indexing. Try again shortly."
}
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,
}),
},
);
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;
}
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.
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}`,
},
],
});
The default model is:
moonshotai/Kimi-K3
If the primary model fails or returns empty content, the sample retries with:
zai-org/GLM-5.3-Flash
The inference client is supplied through the Telnyx Edge binding:
this.env.TELNYX.ai.openai.chat.createCompletion()
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,
);
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
);
You can retrieve the 20 most recent rows through:
curl -X POST \
https://your-worker.telnyxcompute.com/company/acme/rpc/audit
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}` },
);
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 });
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"}'
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
Create your local configuration:
cp .env.example .env
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"
Before deployment, run the project checks:
npm run typecheck
npm run smoke
Generate the Edge types and deploy:
telnyx-edge types
telnyx-edge ship
Add the deployed worker URL to .env as BASE_URL, then register the configured handbook:
npm run bootstrap
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
indexingorfailed. - 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.
Top comments (0)