@sakneen/agent-sdk
v0.5.1
Published
Embed a Sakneen Agent widget in any web application.
Readme
Sakneen Agent SDK
Package: @sakneen/agent-sdk. Legacy InBrief-named exports remain available during migration. Version 0.5.0 is prepared locally; publication is a separate release step.
Mount the Sakneen Agent widget without managing script tags, or consume the server-authoritative Responses protocol through an application-owned transport.
The host application supplies its existing runtime access token, current client, and signed-in user claims. Sakneen uses the client and user context for widget policy and conversation isolation. The customer API independently validates the token and enforces organization, permission, and data scope on every action request.
import { mountSakneenAgent } from "@sakneen/agent-sdk";
const widget = await mountSakneenAgent({
baseUrl: "https://ai-agent.sakneen.com",
version: "v1",
agentId: "agent-id",
authorization: {
getAccessToken: async () => {
const response = await fetch("/api/auth/access-token", { credentials: "same-origin" });
if (!response.ok) throw new Error("Authentication required");
return (await response.json()).accessToken;
},
getClientIdentity: () => ({
headerName: "x-inbrief-client-id",
headerValue: authenticatedSession.inbriefClientId,
}),
getUserIdentity: () => ({
id: authenticatedUser.sub,
email: authenticatedUser.email,
}),
},
getPageContext: () => ({
locale: document.documentElement.lang || "en",
route: window.location.pathname,
pageType: "search-results",
activeFilters: currentFilters,
selectedResourceId: selectedResultId,
visibleResultIds: visibleResults.map((result) => result.id),
}),
});
widget.destroy();Widget runtime 1.8 and later includes a user-facing New conversation action.
Integrators can also call widget.resetConversation?.() to clear the browser
transcript and start a new server-side conversation programmatically.
getPageContext is optional. Its generic values are validated, deduplicated,
and bounded to 20 filters, eight values per filter, 20 visible result IDs, and
8,192 serialized characters. The context helps resolve phrases such as “this
result” or “keep these filters.” It is explicitly untrusted and is never used
to select a workspace, client, organization, user, credential, or permission.
Keep authorization exclusively in the authorization provider.
Opt-in host search synchronization
SDK 0.4.0 and widget runtime 1.13.0 add a separate, optional search-state channel. Text and cards work without it. Nothing is applied by reading model prose or by following a server-provided URL/selector.
First, an administrator reviews a new immutable action revision with this generic contract mapping (example names, not a built-in customer schema):
{
"hostSearch": {
"target": "recordSearch",
"controls": [
{ "input": "regions", "control": "regions" },
{ "input": "maximum", "control": "maximum" }
]
}
}Only declared query constraints and sorting inputs on a side-effect-free record query may be mapped. The mapping participates in the review fingerprint. It does not grant permission or enable an action. Existing published revisions are unchanged until an explicitly authorized configuration rollout.
The host then adds an exact allowlist and an atomic handler to its mount options:
hostSearch: {
target: "recordSearch",
controls: ["regions", "maximum"],
getRevision: () => currentSearchView ? searchRevision : undefined,
// Optional: atomically capture all reviewed controls for inbound reconciliation.
getSnapshot: () => currentSearchView ? {
revision: searchRevision,
controls: { regions: searchStore.regions ?? null, maximum: searchStore.maximum ?? null },
} : undefined,
apply: (controls, { expectedRevision }) => {
if (!currentSearchView || searchRevision !== expectedRevision) return false;
// Use the application's existing store/router; validate host-specific types.
// Null clears a control. Replace every mapped control, not just non-null ones.
searchStore.replaceFilters(controls);
searchRevision = crypto.randomUUID();
// Start normal pagination reset/result refresh separately from this commit.
refreshSearchResults();
return true;
},
}apply must commit synchronously, return true only when accepted, and perform
its own revision comparison immediately before mutation. Increment the revision
on every route, filter, or signed-in-session change, including change-then-
change-back. Return undefined when this widget is not on the configured surface.
The SDK additionally compares client/user identity in mounted widgets, consumes
each request nonce once, expires captures after two minutes, and bounds host
state reads to two seconds. Unknown/missing controls, arbitrary navigation,
malformed values, stale pages, and replayed commands cannot reach the handler.
Commands contain a complete snapshot of the mapped controls, up to 20 controls, 50 scalar values per array, 512 characters per string, and 8,192 serialized characters. They are emitted only from validated successful lookups (including zero matches), never inferred from prose. Failure to apply leaves the answer and cards intact and displays a separate non-application notice. The acknowledgement confirms acceptance of the filters, not completion of the host's data refresh. No host command is emitted after failed final prose; confirmed lookups can instead appear as failure-recovery evidence (below). Ambiguous network retries retain the original captured context; a page reload cannot replay a command into a newly created SDK binding.
For an application-owned Responses transport, pass hostSearchContext when
preparing a response and forward it as host_search_context. Completed responses
may include a validated host_search command. createInBriefHostSearchBinding
can be used by custom hosts; provide a stable current-principal reader as its
second argument, or ensure the revision changes on all identity changes. These
fields are never authorization or model instructions.
getSnapshot opts into inbound reconciliation using the same reviewed mapping.
It must return the exact allowlisted control set and revision from one atomic
store read. Without it, the integration remains outbound-only. The first snapshot
seeds mapped inputs. Subsequently, changed controls update the input baseline;
unchanged controls preserve newer sticky conversation intent, including attempted
refinements whose lookup failed. Explicit tool deltas for the new message take
precedence. Non-sticky mapped inputs use the current snapshot each time. Null or
an empty array clears a mapped field; required upstream inputs must still validate.
Every executed value still passes schema and enabled-option checks. Inconsistent
revisions or missing/unknown controls fail rather than silently drop a filter.
The server stores bounded private comparison hashes, not a second raw host snapshot.
Changing a revision without changing a value is not a filter reset. An explicit
host “start over” operation should call the mounted widget's resetConversation()
alongside resetting its filters; the next snapshot starts a new search. Do not
reuse old revision IDs. Ambiguous transport retries retain their original frozen
snapshot; use a new logical turn for new page changes. A failed snapshot read
stops that submission with a visible refresh message rather than running without
its filters; outbound apply failures still preserve verified text/cards.
Use bounded getPageContext for unstructured reference grounding; it is not this
deterministic control channel. Real integration requires a reviewed mapping and a host implementation;
installing the SDK alone does not update an external application's controls.
Responses client
createInBriefAgentClient manages one-input turns, idempotency, response events,
and conversation IDs without storing credentials or transcripts. The transport
is application-owned so a browser can call a signed same-origin facade while a
trusted server can call the public Responses endpoint. Never put a workspace API
key in browser code.
The following is trusted server-side pseudocode. Do not place this transport or
serverOnlyApiKey in a browser bundle; browser clients should call a signed,
same-origin application facade instead.
import {
createInBriefAgentClient,
toInBriefAgentPageContextRequest,
} from "@sakneen/agent-sdk";
const client = createInBriefAgentClient({
agentId,
transport: async (request) => fetch(
`${baseUrl}/api/v1/agents/${request.agentId}/responses`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-InBrief-API-Key": serverOnlyApiKey,
"Idempotency-Key": request.idempotencyKey,
},
body: JSON.stringify({
input: request.input,
client_id: clientId,
end_user: endUser,
...(request.conversationId
? { conversation_id: request.conversationId }
: {}),
...(request.pageContext
? { page_context: toInBriefAgentPageContextRequest(request.pageContext) }
: {}),
...(request.hostSearchContext
? { host_search_context: request.hostSearchContext }
: {}),
stream: request.stream,
}),
signal: request.signal,
},
),
});
const conversation = client.createConversation({
conversationId: savedConversationId,
onConversationId: saveConversationId,
});
const turn = conversation.prepareResponse("Compare these results.", {
pageContext: {
locale: "en-US",
route: "/search",
pageType: "search-results",
activeFilters: { status: ["available"], bedrooms: 3 },
visibleResultIds: ["result-1", "result-2"],
},
});
const stream = turn.stream();
for await (const event of stream) {
if (event.type === "response.output_text.delta") render(event.delta);
}
await stream.completed;Typed errors and retries
HTTP failures, response.failed transport events, and invalid response streams
reject with InBriefAgentResponseError. Branch on code, use status and
retryAfter for bounded retry policy, and retain requestId, responseId,
conversationId, and traceId for support correlation. Do not parse the
human-readable message.
RESPONSE_FINALIZATION_PENDING, RESPONSE_IN_PROGRESS, CONVERSATION_BUSY,
and IDEMPOTENCY_STATE_UNAVAILABLE do not authorize a fresh logical request.
Retry the same prepared turn with bounded backoff. The widget retains its exact
key, original conversation ID and context through these errors and reloads.
After recovery, it replaces the transient notice with the completed answer.
An expired execution lease may take up to two minutes to become reclaimable.
The server records provider admission before calling the model, including when budgets are disabled. After a process stop, it resumes a pre-provider turn, or materializes an already committed answer without another model/action call. If provider work began but no completed answer was committed, recovery preserves the failed key and durable lookup evidence; it does not replay uncertain work. Committed answers retain their original message ID, timestamp, references and metered usage. This is not a guarantee that every interrupted response succeeds.
The local SDK also exposes error.lookupOutcomes, and response.failed events
can include response.lookup_outcomes. These contain up to four durably saved
lookups with counts and optional reviewed cards (up to five each). They never
contain raw action input, private lookup IDs, host commands, or completed prose.
The response remains failed. Show an incomplete-answer notice alongside the
cards; current_filters_confirmed: false means historical results, not matches
for the latest filters. Only call a zero count “no matches” when
total_count_known is true. An unknown total must not imply exhaustive results
or a globally best/cheapest record.
The widget keeps recovered cards in its scoped session display as lookup
evidence, not an assistant message, and restores them after reload. Retrying the
same frozen idempotency key after a finalized failure returns HTTP 409
IDEMPOTENT_RESPONSE_FAILED with saved, re-scoped lookup evidence, without
another provider/action execution. A new follow-up uses a new key. Process-stop
recovery revalidates conversation scope and fences expired writers; recovery
metadata is not a guarantee of uninterrupted delivery.
Result-card follow-ups use the action's reviewed referenceField, not a card's
title or display link. The server retains up to four recent distinct result
sets, five positions each, independently of prose pruning within the existing
4,096-character structured-history cap. This includes repeated searches using
the same action. These are historical references, not proof of current matches
or permission; further details require a fresh authorized action.
Exact string IDs support the action transport's existing 512 UTF-8 byte limit.
Invalid or oversized IDs are unavailable, never truncated. IDs longer than 96
JSON-serialized characters use private handles and the conditional internal
inbriefReadResultReferences tool. It reads only the authorized conversation's
retained data, accepts up to five handles per call and four calls per turn, and
shares the existing 8,192-character/estimated-token result budget with discovery
and action output. It is not a public SDK endpoint or authorization token.
Short-ID turns need no extra tool. Neither this name nor discoverAvailableActions
may be used as a configured action name. No host adapter change is required.
import { InBriefAgentResponseError } from "@sakneen/agent-sdk";
try {
await turn.stream().completed;
} catch (error) {
if (error instanceof InBriefAgentResponseError) {
reportFailure({
code: error.code,
status: error.status,
retryAfter: error.retryAfter,
requestId: error.requestId,
traceId: error.traceId,
});
}
throw error;
}For current-turn image input, pass ordered inline parts. The SDK freezes the prepared parts for safe retries; the server validates image bytes and persists metadata only, not the base64 payload.
const turn = conversation.prepareResponse([
{ type: "input_text", text: "Describe this floor plan." },
{ type: "input_image", media_type: "image/png", data: base64Png, filename: "plan.png" },
]);Prepare a turn once and reuse that prepared object for a transport retry. Its input, idempotency key, and original conversation ID are frozen, including an absent ID on the first turn. A newly prepared turn uses the conversation ID captured from the previous response.
baseUrl and version are the only Sakneen runtime location settings. For example, move a customer from staging to production by changing baseUrl; adopt a future API-compatible widget by changing version from v1 to v2.
Runtime identity contract
getAccessToken, getClientIdentity, and getUserIdentity are called before the widget checks availability and before every chat turn. The SDK sends the returned values only for that request. The ID and email are host claims used by Sakneen for widget policy and conversation isolation; they are not customer-API authorization. The customer API must independently validate the delegated token and selected client for every action request.
For a multi-tenant host, getClientIdentity sends the current tenant using exactly one client selector: organization-domain, organization-auth-id, or x-inbrief-client-id. For a host without tenant subdomains, select the intended Sakneen client directly:
getClientIdentity: () => ({
headerName: "x-inbrief-client-id",
headerValue: "client-id",
})Both paths use the same access check. The selected client must exist and have the published agent assigned.
User access policy
Sakneen administrators own widget access. Each client is either off, on for all signed-in users, or on for a specific email list. getUserIdentity supplies the host's claimed runtime user ID and email; the SDK normalizes the email before sending it. Sakneen applies grants to that normalized email, scoped to the exact workspace and client. Raw access tokens and raw external user IDs remain request-scoped; conversations may retain a one-way user hash and the normalized email for isolation and workspace-visible operations.
The SDK applies this contract before mounting and again for every chat turn. Unassigned clients and unauthorized emails receive no launcher.
