Connect your own AI agent
The JavaScript agent client In testing
Your server can read private cases and reply through Templ's agent client. You supply the model, approved protocol knowledge and hosting. Templ supplies the case API and team inbox.
Templ charges nothing extra for an agent you run yourself. Its resolved cases cost the same as those your team resolves.
Start with answers a person reviews. Turn on automatic replies only for case types your team approved. Leave uncertain answers, account recovery, disputed transactions and security cases for a person. The agent client never signs or sends transactions. It never handles a user's funds.
Give the worker its own account
Use Node 22 or newer and a separate service wallet. Keep its key in a secret manager or a mode-600 file on the server. Never put it in a browser, prompt, argument or log. An EVM service wallet needs no funds to sign in. Keep this wallet separate from the manager's account.
Download https://usetempl.com/agent-client.mjs. Compare its SHA-256 with assets.agentClientSha256 from app status before importing it. Require agents.sdkVerified and support.conversationsVerified. Check these flags each time the worker starts.
This download script fails on redirects or a hash mismatch:
npm init -y
npm install viem@2.55.0
Save this as download-client.mjs. Then run node download-client.mjs:
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
const origin = "https://usetempl.com";
async function read(path) {
const response = await fetch(origin + path, {
redirect: "error",
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
return response;
}
const status = await (await read("/api/status")).json();
if (!status.agents?.sdkVerified || !status.support?.conversationsVerified)
throw new Error("Agent client is unavailable.");
const bytes = Buffer.from(
await (await read("/agent-client.mjs")).arrayBuffer(),
);
if (
createHash("sha256").update(bytes).digest("hex") !==
status.assets?.agentClientSha256
)
throw new Error("Agent client hash mismatch.");
await writeFile("agent-client.mjs", bytes);
Sign in with the service wallet. Save the returned account ID:
import { readFile } from "node:fs/promises";
import { privateKeyToAccount } from "viem/accounts";
import { TemplChatClient } from "./agent-client.mjs";
const client = new TemplChatClient({
signer: privateKeyToAccount(
(await readFile(process.env.TEMPL_SIGNER_KEY_FILE, "utf8")).trim(),
),
roomId: process.env.TEMPL_ROOM,
apiOrigin: "https://community.usetempl.com",
appOrigin: "https://usetempl.com",
});
const session = await client.signIn();
console.log(session.principalId); // An account ID, never a key or cookie.
signIn() checks the wallet sign-in challenge before signing. Session cookies stay in the client's memory. The client binds them to the chosen API host. A restart needs another sign-in. The Origin header alone proves no identity.
The workspace manager grants access in Team. Paste the worker's account ID. Tick the AI agent box and the confirmation box. Choose Give access.
The worker gets the AI support role. It can read and answer cases. It cannot assign cases, change saved replies or read reports.
To script this setup, use a separate manager client. Review each role change before passing { confirmed: true }. The manager needs a recent wallet sign-in. Register the bot before granting its role, so every reply carries the label:
await manager.saveManualRole(
{
id: "ai-agent",
name: "AI support",
moderationPermissions: ["handleConversations"],
},
{ roomId },
{ confirmed: true },
);
await manager.registerBot(
{
principalId: agentPrincipalId,
displayName: "Protocol helper",
},
{ roomId },
);
await manager.assignRole(
"ai-agent",
agentPrincipalId,
true,
{ roomId },
{ confirmed: true },
);
The role follows the team’s AI modes. AI off hides the case. AI drafts only blocks replies. Registering the bot labels replies AI agent. Registration alone grants no access.
Never give the agent manager, role-management or unsolicited-message powers. If the workspace requires an invitation, the account must accept a valid one.
A handoff alone does not make a case billable. Mark real answers with provenance.reason: "answer" when sending them. The user must receive the answer before the case is marked solved. Questions, quote summaries, safety notices and drafts do not count as AI answers. Replies without that reason do not count either. Time, message and storage limits still apply. Read the billing rule before answering cases.
Connect your answer function
Put your model code in answer.mjs. Export decide({ messages }). Return { kind: "reply", text } or { kind: "handoff" }.
Apply your approval rules outside the model's control. This default sends every case to a person. Connect your agent only after testing its answers:
export async function decide({ messages }) {
// Call your existing agent with approved knowledge and these messages.
// Return { kind: "reply", text: approvedAnswer } only after your policy checks.
return { kind: "handoff" };
}
Pass only the messages needed for this case. The example passes the latest message page with member or team role labels. It leaves out author IDs, internal notes and other cases.
Message text can contain wallets or secrets. Before sending text to an external model, remove recovery phrases and values that could be private keys. Send data deletion and export requests to a person. Add transaction details only when the user shared them and the case needs them.
Tell users which model provider receives their messages. Use the retention settings you agreed with the protocol. Treat message text as untrusted input. Refuse instructions to reveal notes or change permissions. Set a timeout on the model call. Leave failures for a person.
Read, answer and hand off
Append this loop to the sign-in example. Import decide from your file. Run one worker per service account and workspace. Give it a private working directory that survives restarts. Each pass reads every page of the open queue.
The worker saves each reply's text and UUID before sending. An uncertain send stops the process. Review the saved reply before restarting with TEMPL_RETRY_PENDING=yes. The retry uses the same ID and text. Keep the state file private. Remove it when you stop using the worker.
Templ refuses these replies before storage:
- A recognized recovery phrase or private key:
422, reasonsecret-refused. - A request for a private key or recovery phrase:
422, reasonsecret-request-refused. - A request to sign, approve or send anything:
422, reasonunsafe-ask.
Wallet sign-in happens only in the sign-in card. Templ stores nothing from a refused reply. The user sees no reply. Repeating the text gets the same refusal. The loop drops that reply and leaves the case for a person.
While the model works, the loop calls markTyping(caseItem.id). It repeats the call until the model returns. The user's message response reports typing: "agent", or "team" while a person types. It never names the account. A reply clears the mark.
Templ keeps typing marks only in memory. It never stores, exports or bills them. Only an account that could reply can set a mark. The service applies the same refusal rules as replies. The loop ignores a failed mark and waits before sending another.
import { open, readFile as readState, rename } from "node:fs/promises";
import { setTimeout as wait } from "node:timers/promises";
import { newMessageId } from "./agent-client.mjs";
import { decide } from "./answer.mjs";
const file = "support-state.json";
let state;
try {
state = JSON.parse(await readState(file, "utf8"));
} catch (error) {
if (error.code !== "ENOENT") throw error;
state = {
room: process.env.TEMPL_ROOM,
principal: session.principalId,
done: {},
handoffs: {},
pending: null,
};
}
if (
state.room !== process.env.TEMPL_ROOM ||
state.principal !== session.principalId
)
throw new Error("Use a separate state file for this workspace and account.");
async function save() {
const handle = await open(file + ".tmp", "w", 0o600);
try {
await handle.chmod(0o600);
await handle.writeFile(JSON.stringify(state));
await handle.sync();
} finally {
await handle.close();
}
await rename(file + ".tmp", file);
}
async function sendPending() {
await requireAgent();
const pending = state.pending;
const page = await client.conversationMessages(pending.thread);
const delivered = page.messages.some(
(m) => m.clientId === pending.clientId && m.author === session.principalId,
);
if (!delivered) {
const latest = page.messages.at(-1);
if (
Date.now() - pending.createdAt >= 7 * 86_400_000 ||
latest?.id !== pending.replyTo ||
(latest?.revision ?? 1) !== pending.memberRevision ||
!page.conversation.canPost ||
page.conversation.staffMessagesStopped ||
page.conversation.state !== "open" ||
page.conversation.assignment
)
throw new Error(
"Pending reply needs human review; leave its ID unchanged.",
);
try {
await client.replyInConversation(pending.thread, {
text: pending.text,
clientId: pending.clientId,
replyTo: pending.replyTo,
provenance: { reason: "answer", sources: [] },
});
} catch (error) {
if (
error.status !== 422 ||
!["secret-refused", "secret-request-refused", "unsafe-ask"].includes(error.reason)
)
throw error;
// Templ stored nothing. Leave this request for a person.
state.handoffs[pending.thread] = true;
state.pending = null;
await save();
return;
}
}
state.done[pending.thread] = pending.replyTo;
state.pending = null;
await save();
await wait(5_000);
}
async function requireAgent() {
const status = await client.conversationStatus();
const { bots } = await client.bots();
if (
!status.available ||
!status.team ||
!bots.some((b) => b.principalId === session.principalId)
)
throw new Error("Agent permission or registration is missing.");
}
try {
if (state.pending) {
if (process.env.TEMPL_RETRY_PENDING !== "yes")
throw new Error("Review the pending reply before enabling its retry.");
await sendPending();
}
while (true) {
await requireAgent();
const cases = [];
let cursor;
do {
const queue = await client.conversations(
undefined,
cursor ? { filter: "open", cursor } : { filter: "open" },
);
cases.push(...queue.conversations);
if (queue.nextCursor && queue.nextCursor === cursor)
throw new Error("The open queue repeated a page.");
cursor = queue.nextCursor ?? undefined;
} while (cursor);
for (const caseItem of cases) {
if (
state.handoffs[caseItem.id] ||
caseItem.assignment ||
!caseItem.canPost ||
caseItem.staffMessagesStopped
)
continue;
const page = await client.conversationMessages(caseItem.id);
const latest = page.messages.at(-1);
// A widget guest who later proves a wallet keeps their earlier
// messages; those authors are listed in formerMembers.
const memberSide = new Set([
page.conversation.member,
...(page.conversation.formerMembers ?? []),
]);
if (
!latest ||
!memberSide.has(latest.author) ||
state.done[caseItem.id] === latest.id
)
continue;
// Tell the member an answer is being written. A failed mark is
// ignored; the service drops each mark about 12 seconds later. One
// mark at a time keeps slow marks from queuing ahead of the reply.
let marking = false;
const markTyping = () => {
if (marking) return;
marking = true;
client
.markTyping(caseItem.id)
.catch(() => {})
.finally(() => {
marking = false;
});
};
markTyping();
const typing = setInterval(markTyping, 8_000);
let decision;
try {
decision = await decide({
messages: page.messages.map((m) => ({
role: memberSide.has(m.author) ? "member" : "team",
text: m.text,
})),
});
} finally {
clearInterval(typing);
}
const handoff = decision?.kind !== "reply";
if (handoff) {
await client.handOff(caseItem.id, {
reason: "unsure",
ifLatest: { id: latest.id, revision: latest.revision ?? 1 },
});
state.done[caseItem.id] = latest.id;
state.handoffs[caseItem.id] = true;
await save();
continue;
}
const text = decision.text;
if (
typeof text !== "string" ||
!text.trim() ||
Buffer.byteLength(text) > 4_000
)
throw new Error("Answer needs human review.");
state.pending = {
thread: caseItem.id,
replyTo: latest.id,
memberRevision: latest.revision ?? 1,
clientId: newMessageId(),
text,
createdAt: Date.now(),
};
await save();
await sendPending(); // Rechecks the latest message and staff claim before sending.
}
await wait(15_000);
}
} catch (error) {
// Log a status code only; never log messages, model prompts or credentials.
console.error(
"Support worker stopped; operator review required.",
error.status ?? "local",
);
process.exitCode = 1;
} finally {
await client.signOut().catch(() => {});
}
The handoff keeps the case open for a person. The example stops answering until an operator clears its entry in handoffs. Your team should check the inbox for cases that need a person. This example promises no response time. It never resolves a case automatically.
The final read reduces outdated answers. It cannot stop a person from replying at the same time. Agree which cases the worker handles and keep a person supervising it. Do not run workers against the same state file. The Agent API reference covers replies with ifLatest for checking message changes.
Stop on lost permission, session expiry or a paused workspace. For a 429, wait at least the returned retry delay. Sign in again after a 401. Stop after a 403 or a stopped-user refusal. Keep pending reply IDs so you can check uncertain sends. Never replace an ID to bypass a refusal.
Check before inviting users
Use separate user and team accounts. Check these actions:
- An approved answer and an uncertain-answer handoff.
- A lost response retried with the same ID.
- A process restart with a saved pending reply.
- Removed team access and a stopped case.
- A person claiming the case.
Check that the model gets only the chosen case's messages. It must get no internal notes. Check that the widget labels replies AI agent. Check billing against the resolved-case rules.
Local tests do not prove hosted answer quality, response time or alert delivery.
In testing means the feature is built and not yet open to every workspace.