API REFERENCE
The Widget API
The widget as an API. Drop in one script tag, then configure it with data-* attributes, pass the signed-in user through window.ERAConfig, drive it from your own code with window.ERA, and re-skin it with CSS variables.
The widget is a vanilla JavaScript bundle with zero dependencies that renders inside a Shadow DOM, so it never collides with your styles. You embed it with a single script tag. Everything past that is optional: attributes on the tag for look and behavior, a global config object for who the user is, a small JS API for driving it, and a set of CSS variables and parts for theming it to your brand.
The embed snippet
Paste this once, near the end of your page body. Only data-api-key is required.
<script src="https://agentifys.ai/static/widget.js" data-api-key="era_your_project_key" data-title="Assistant" data-primary-color="#6366f1" data-placeholder="Ask me anything..." data-position="bottom-right" ></script>
document.currentScript, which is null under type="module" and when a bundler inlines the file. In that case it falls back to the last <script> on the page, finds no data-api-key on it, logs [Agentifys Widget] data-api-key is required and returns without rendering anything. Injecting the tag from JavaScript is fine as long as it is a real script element — see Embed the widget for the SPA pattern.data-* attributes
Configure the widget declaratively on the script tag. Every attribute is read once, when the script runs; changing one afterwards has no effect on the live widget.
| Field | Type | Description |
|---|---|---|
data-api-keyrequired | string | Your project key, era_.... Identifies the project and gates allowed origins. Without it the widget logs "[Agentifys Widget] data-api-key is required" and renders nothing. |
data-primary-color | string | Accent color for the launcher and buttons. Defaults to #6366f1. It seeds --era-accent and is also baked into the widget’s rgba() glows at boot — see Theming before you override the accent in CSS. |
data-title | string | Header title, and the launcher’s aria-label. Defaults to "AI Assistant". |
data-placeholder | string | Placeholder text in the message input. Defaults to "Ask me anything…". |
data-position | "bottom-right" | "bottom-left" | Which corner the launcher sits in. Defaults to bottom-right; any value that does not contain "right" is treated as left. |
data-theme | "dark" | "light" | Color scheme. Defaults to "dark"; set "light" to switch. It does not follow the host page. |
data-max-length | number | Maximum characters in one message. Defaults to 4000. It is applied as the textarea’s maxlength, drives the remaining-characters counter, and caps a quoted "Ask AI" selection at this value minus 200. |
data-feedback | "off" | Thumbs up / thumbs down under finished answers. On by default; set to "off" to hide the controls. |
data-options | "off" | Clickable choice buttons the agent can render. On by default; set to "off" to disable them and leave the choices as plain text. |
data-status-style | "professional" | "playful" | Tone of the live tool-status labels while the agent works. Defaults to "professional". |
data-base-url | string | Absolute origin the widget sends every request to. Defaults to the origin of the script’s own src, so you only set this to route through your own proxy. It does not hide the project key — see the note below. |
data-dictation | "off" | Microphone that dictates into the composer. On by default where the browser exposes SpeechRecognition, and hidden automatically where it does not (Firefox). Set to "off" to hide it. Note that in Chrome the browser sends the audio to Google for transcription, so turn it off if that is not acceptable for your users. |
data-read-aloud | "off" | Speaker toggle that reads answers aloud as they stream. On by default where the browser exposes speechSynthesis; set to "off" to hide it. |
data-select-ask | "on" | OPT-IN. Set to "on" to show an "Ask AI" pill when a visitor selects text anywhere on your page. Off by default: it watches selections across the whole page and puts the selected text into a transcript your admins can read, so you should turn it on deliberately. |
data-point-at | "off" | Whether the AGENT may spotlight elements on your page. On by default; set to "off" to withdraw it. Your own ERA.point() calls keep working either way, since that is your code acting on your page. |
data-command-key | string | Opt in to the command palette and choose its key, e.g. "k" for Ctrl/Cmd+K. Off unless set. The shortcut is scoped to the widget, so it fires only when focus is already inside the panel; to open it from anywhere in your app, bind your own shortcut to ERA.openPalette(). |
data-base-url cannot keep your project key server-side. The key is an attribute in your page's HTML and the widget sends it as Authorization: Bearer era_... on every request, whatever the base URL is. What a proxy buys you is same-origin traffic (your CSP, your logs, your edge rate limiting) and the ability to point at a self-hosted backend. The protections that actually contain a copied key are the project's origin allowlist and its rate limits. If you proxy a project that has an origin allowlist, forward the browser's Origin header: the allowlist is matched against that header, so a proxy that drops it turns every message into ORIGIN_NOT_ALLOWED.window.ERAConfig (who the user is)
Set this global before the script loads to tell the widget who is signed in. The id is required; the rest personalizes the agent and feeds policies through data. In signed mode you also attach a _ts and _sig, which unlock private uploads, the vault, per-user MCP OAuth, and the My-tasks panel.
<script>
window.ERAConfig = {
user: {
id: "u_42",
name: "Sara Arabiat",
email: "sara@example.com",
role: "admin",
data: { plan: "pro", region: "MENA" },
// signed mode only, produced server-side:
_ts: 1735689600,
_sig: "a1b2c3..."
}
};
</script>| Field | Type | Description |
|---|---|---|
user.idrequired | string | Stable identifier for the end user. Scopes sessions, private knowledge, credentials, and tasks. |
user.name | string | Display name, available to the agent. |
user.email | string | User's email, available to the agent. |
user.role | string | Your own role label, usable in policies. |
user.data | object | Arbitrary key/values the agent can read and that policies match on (user.data.*). |
user._ts | number | Signed mode: Unix seconds when the identity was signed. Valid for 300 seconds. |
user._sig | string | Signed mode: HMAC-SHA256 over the canonical identity. Produced server-side, never in a public env var. |
_sig in the browser. The signing secret is server-side only. See the signed identity guide for the exact canonicalization.client_tz field, so the agent reads "tomorrow at 9" as the user's 9am. It is deliberately kept outside user.*, which is signed. If your backend knows the user's real zone, sign a timezone field into the identity — a verified zone takes precedence.When the widget reads it
The widget does not snapshot ERAConfig at boot. It reads window.ERAConfig.user fresh at the moment it makes a request — every POST /v1/chat, every private upload, every scheduled-task and MCP OAuth call. Replacing the object hands a running widget a new identity; there is nothing to reload and no re-injection needed.
Two things are read only once, while the script is running:
- The console warning when no
user.idis set. - The
GET /v1/project-configfeature gate below, which is skipped entirely ifwindow.ERAConfig.user.idis absent at that moment. SetERAConfigbefore the widget tag. Setting it later still personalizes messages, but the attach button, the My-tasks footer and the Connect prompts stay hidden until the script is injected again.
Keeping a signature fresh
A _sig is valid for 300 seconds after its _ts. On a page that stays open longer than that — a dashboard, an admin console, anything a user leaves in a tab — one signature obtained at page load will expire underneath them. Because the identity is re-read per request, the fix is to re-sign on a timer and assign the result:
// Your endpoint returns the signed context ({ id, name, ..., _ts, _sig }).
async function refreshEraIdentity() {
const signed = await fetch("/api/era-identity").then((r) => r.json());
window.ERAConfig = { user: signed }; // the next widget request uses this
}
refreshEraIdentity();
setInterval(refreshEraIdentity, 240000); // 240s, comfortably inside the 300s windowIDENTITY_SIGNATURE_EXPIRED and the widget shows "Your session expired — please reload the page." The partial answer is kept and a Retry link is added underneath. Retry re-sends with whatever ERAConfig.user holds at that moment, so refreshing the identity and clicking Retry recovers the turn without a reload. One exception: a thumbs up or down reuses the signature captured when that turn was sent, so in signed mode a vote cast more than five minutes after the reply is rejected by the server and the widget still says "Thanks for the feedback".GET /v1/project-config (the boot handshake)
/v1/project-configThis is the first request the widget makes, before it loads any history. It asks the server which optional affordances this project is allowed to show. It is a public endpoint: the project key goes in the query string, there is no Authorization header and no origin check, so anyone holding the key can read it. Keep nothing sensitive in the project name or tagline.
| Field | Type | Description |
|---|---|---|
api_keyrequired | string | Query parameter. Your project key, era_.... An unknown key returns 404 with { "detail": "Project not found" }. |
curl "https://agentifys.ai/v1/project-config?api_key=era_your_project_key"
Response:
{
"name": "Acme Support",
"tagline": "",
"logo_color": "#6366f1",
"identity_mode": "signed",
"kb_uploads_enabled": true,
"scheduling_enabled": true,
"mcp_oauth_connect_enabled": true,
"mcp_oauth_servers": [
{ "server_id": "srv_7a21", "label": "Google Drive" }
]
}| Field | Type | Description |
|---|---|---|
identity_mode | "open" | "signed" | The project’s identity mode. Every flag below is false in open mode, because a browser-supplied id is forgeable. |
kb_uploads_enabled | boolean | Reveals the attach button (::part(attach)), which uploads a private document for this user. True only when identity_mode is "signed" and indexing is not paused server-wide (the INDEXING_ENABLED environment switch). |
scheduling_enabled | boolean | Reveals the My-tasks footer button, and starts the unseen-results badge and banner. True when identity_mode is "signed" and scheduling is switched on for the project. |
mcp_oauth_connect_enabled | boolean | Lets the widget render "Connect your account" buttons. True when the project has at least one active per-user OAuth MCP server; signed mode only. When it is true the widget then calls GET /v1/oauth/mcp/servers for the per-user connected state. |
mcp_oauth_servers | array | server_id and label for each of those servers. Returned for API callers; the widget itself reads only the boolean above. |
name, tagline, logo_color | string | Project display fields. Returned for your own UI — the widget does not use them; its header text and accent come from data-title and data-primary-color. |
window.ERAConfig.user.id is not set at boot, since every flag it returns is per-user. That is the same silent outcome from a different cause: the widget works, the extras are absent.window.ERA (JavaScript API)
Once the script has loaded, window.ERA lets you drive the widget from your own code.
| Field | Type | Description |
|---|---|---|
ERA.open() | function | Open the chat panel. |
ERA.close() | function | Close the chat panel. |
ERA.sendMessage(text) | function | Send a message as the user, as if they typed it. Opens the panel if closed. |
ERA.clear() | function | Start a fresh conversation: aborts an in-flight reply, drops the stored session id from localStorage, and resets the panel to its welcome state. The previous transcript stays on the server — this is not DELETE /v1/sessions. Required on sign-out; see the callout below. |
ERA.onMessage(fn) | function | Register a callback that fires as the agent streams. See the payload below. There is one global slot: a second call replaces the first, ERA.onMessage(null) is the only way to unregister, and the callback survives ERA.destroy(). |
ERA.openTasks() | function | Open the My-tasks panel. It opens whether or not scheduling_enabled came back true — with scheduling off you get an empty panel, so gate your own button on the same project-config call the widget makes. |
ERA.connectMcp(serverId) | function | Start the per-user MCP OAuth flow for a server, from your own button. |
ERA.point(target, label?) | function | Spotlight a real element on your page: dims the page, rings the element and scrolls it into view, with an optional label. Returns false if the target was not found. |
ERA.highlight(target) | function | Same as point() with no label. |
ERA.scrollTo(target) | function | Scroll the element into view without dimming or ringing it. |
ERA.clearPoint() | function | Remove the spotlight immediately. It also clears itself on scroll, on resize, on the next message, and after a few seconds. |
ERA.ask(text) | function | Open the panel with text quoted into the composer, so the visitor only types their question. This is what the "Ask AI" selection pill calls. |
ERA.openPalette() | function | Open the command palette. No-op unless data-command-key is set — use this to trigger it from your own UI or your own shortcut. |
ERA.destroy() | function | Remove the widget entirely: aborts an in-flight stream, stops the mic and any speech, takes off every listener it added to your document, and removes its host element. It also deletes window.ERA, so guard a second teardown — window.ERA?.destroy() — or keep your own reference to the object. |
ERA.clear() on sign-out. The session id is stored in localStorage under era_sid_ plus the last eight characters of your project key — keyed by project, never by user. Whoever opens the page next in that browser inherits it. In signed identity mode the server refuses the mismatch: every send comes back SESSION_NOT_FOUND, the widget shows "Session not found." and keeps the stored id, so the second user is stuck on that browser until ERA.clear() runs or site data is cleared. In open mode there is no such check and the second user simply resumes the first user's conversation. Treat it as part of your sign-out path, not a courtesy.destroy() before building a new one. What does leak is a host element you remove yourself — the listeners the widget put on your document survive that, each holding a detached DOM tree alive. There is a keydown handler in every configuration, plus mouseup, keyup and mousedown when data-select-ask is on. ERA.destroy() is what takes them off. The one thing it does not reset is the onMessage callback: it is a global slot, so it outlives the widget and the next instance will call it. Re-register it after a rebuild, or clear it with ERA.onMessage(null).target is either a bare token, matched as [data-era-id="<token>"], or a full CSS selector. A missing target is deliberately silent, so the answer still reads normally. The agent is restricted to the token form — it can only name elements you have explicitly marked with data-era-id, never an arbitrary selector, so a confused model cannot ring a password field or someone else's row. Full selectors are available only to your ownERA.point() calls.The ERA.onMessage(fn) callback receives an event object as the agent works. Use it to mirror the conversation elsewhere, drive UI, or react to tool activity. It is a single global slot rather than a subscription: registering again replaces the previous callback, and there is no unsubscribe other than passing null.
| Field | Type | Description |
|---|---|---|
type | string | Event kind: "session", "text", "tool_start", "tool_end", "confirmation_required", "error", or "done". |
content | string | On "text", the incremental chunk of the reply; on "error", the error code or message the server sent. |
full | string | On "text" and "done", the full reply so far, already concatenated for you. |
session_id | string | On "session" only: the id of the conversation this turn belongs to. It is the first event of every turn, not only the first turn. |
name | string | On "tool_start", "tool_end", and "confirmation_required", the tool involved. |
done is not a guaranteed terminator. Every other event is forwarded straight from the server stream; done is emitted by the widget when it reads the stream's [DONE] sentinel. It therefore does not fire when the visitor presses Stop, and a dropped connection emits nothing at all — the widget shows "Connection error — please try again." in the panel without calling you. If your UI enters a loading state on text, give it its own timeout rather than waiting on done. confirmation_required also ends the stream: approving or cancelling starts a new turn, so you will see a fresh session event and a fresh sequence after it.ERA.onMessage((e) => {
if (e.type === "text") {
console.log("so far:", e.full);
} else if (e.type === "tool_start") {
console.log("calling tool:", e.name);
} else if (e.type === "error") {
console.log("stream error:", e.content);
} else if (e.type === "done") {
console.log("final reply:", e.full);
}
});
// Kick off a conversation from your own button:
document.querySelector("#help").addEventListener("click", () => {
ERA.sendMessage("How do I reset my API key?");
});ERA does not bypass those confirmations.Letting the agent point at your page
Instead of describing where a button is, the agent can put a ring on the real thing. It opts in by ending its reply with a fenced era-ui block. The widget parses the block out, runs it, and never renders it as text — the same explicit-contract approach as the quick-reply choices block.
It is in Settings, here:
```era-ui
{"action":"point","target":"invite","label":"Invite a teammate"}
```| Field | Type | Description |
|---|---|---|
actionrequired | "point" | "highlight" | "scrollTo" | point dims the page and rings the element with a label; highlight rings it without a label; scrollTo only brings it into view. |
targetrequired | string | A data-era-id token, or any CSS selector. |
label | string | Short caption shown beside the ring on "point". |
Two things to set up on your side, and the agent knows the block format already:
- Mark the elements you want reachable with
data-era-id, for example<button data-era-id="invite-teammate">. Prefer this over CSS selectors, which break the next time you restyle. - Tell the agent those ids exist. Add a section headed exactly Screen elements you can point at to your project's system prompt, listing each id and what it does. The agent is instructed to use only ids from that section, so until you add it nothing is highlighted and replies simply describe the path in words.
## Screen elements you can point at - invite-teammate — the Invite button on Settings, Team - api-keys — the API keys panel on Settings, Developers - billing-plan — the plan selector on Billing
data-point-at="off".Theming (CSS variables + parts)
The widget renders in an open Shadow DOM and exposes a supported theming surface, so you can re-skin it to your brand without editing the widget or overriding its internal classes. Two building blocks: a set of --era-* CSS custom properties for colors and radii, and ::part() handles for styling specific elements. Prefer these over targeting internal ids, which can change between widget versions.
CSS variables
| Field | Type | Description |
|---|---|---|
--era-accent | color | Accent for the launcher, the send button, the header status dot, the attach / mic / speaker buttons, the "Ask AI" pill, and the spotlight ring and its label. Defaults from data-primary-color. It does not reach message links, the numbered chips on option buttons, or the Retry link — those bake data-primary-color in at boot. |
--era-accent-rgb | "r,g,b" | The accent as raw channels. Only three things read it: the command palette’s input focus border, its hovered and selected rows, and the mic / speaker buttons. Every other rgba() glow is baked from data-primary-color at boot and this variable will not move it — see the callout below. |
--era-panel | color | Panel and header background. |
--era-surface | color | Assistant bubble and inner surfaces. |
--era-user | color | User bubble background. Defaults to the accent. |
--era-text | color | Primary text color. |
--era-text-dim | color | Muted text (placeholder, close icon, counts). |
--era-border | color | Hairline borders and dividers. |
--era-input-bg | color | Message input background. |
--era-input-border | color | Message input border. |
--era-radius | length | Panel corner radius. |
--era-radius-bubble | length | Message bubble corner radius. |
--era-radius-input | length | Input (and pill) corner radius. |
--era-font | string | Font stack for the whole widget. |
data-primary-color, not only in CSS. The widget converts that hex to r,g,b once at boot and writes the channels literally into most of its translucent rules — the launcher's drop shadow, the halo around the header dot, the welcome icon tile, the input focus ring, the option-button chips, the tasks banner. Those literals are already in the stylesheet by the time your override lands, so re-pointing --era-accent and --era-accent-rgb in CSS moves the solid accents but leaves every one of those halos on the old colour: a purple launcher with an indigo glow. Change data-primary-color and the two variables follow it automatically. Use the CSS variables for the surfaces, the text, the radii and the font, and for accent tweaks on top of a matching data-primary-color.Parts (::part hooks)
| Field | Type | Description |
|---|---|---|
::part(launcher) | button | The floating chat button. |
::part(panel) | element | The chat panel container. |
::part(header) | element | The panel header bar. |
::part(messages) | element | The scrolling message list. |
::part(footer) | element | The input footer. |
::part(input) | textarea | The message input. |
::part(send) | button | The send / stop button. |
::part(attach) | button | The attach-document button. |
::part(bubble) | element | Any message bubble. |
::part(bubble-user) | element | User message bubbles only. |
::part(bubble-assistant) | element | Assistant message bubbles only. |
::part(title) | element | The header title text. Hide it with display:none if you render your own header. |
::part(close) | button | The header close button. |
::part(welcome) | element | The empty-state greeting shown before the first message. |
::part(option) | button | A quick-reply option button, when the agent offers choices. |
::part(banner) | element | The "new scheduled results" notice strip. |
::part(tasks) | element | The My-tasks panel (signed mode). |
::part(mic) | button | The dictation microphone button. |
::part(speak) | button | The read-aloud speaker toggle. |
::part(ask) | button | The "Ask AI" pill shown on a text selection. |
::part(spotlight) | element | The on-page spotlight overlay used by point(). |
::part(palette) | element | The command palette overlay. |
How to apply it
Method A — set the variables on the widget host element from your page CSS. Custom properties inherit through the open shadow boundary, so this re-themes everything that reads them:
#era-widget {
--era-accent: #7c3aed;
--era-panel: #0b0b12;
--era-surface: #171722;
--era-radius-bubble: 20px;
}Method B — inject a stylesheet into the open shadow root. Use this for ::part() rules or when you need a guaranteed override:
const w = document.getElementById("era-widget");
const s = document.createElement("style");
s.textContent = `
:host { --era-accent: #7c3aed; }
::part(panel) { box-shadow: 0 12px 40px rgba(0,0,0,.35); }
::part(send) { border-radius: 12px; }
`;
w.shadowRoot.appendChild(s);#panel, .bbl) still works through the open shadow root but is brittle, because those names can change between versions.Try it
Adjust the controls and copy the generated CSS. The preview mirrors how the widget reads these variables.
The preview reacts to the same variables the widget reads. Copy the CSS below and apply it (see the two methods above), and set the same colour on data-primary-color so the widget's baked-in glows match it.
/* Put the same colour on the script tag — data-primary-color="#2F6FED" —
because the widget bakes those channels into its glows at boot. */
#era-widget {
--era-accent: #2F6FED;
--era-accent-rgb: 47,111,237;
--era-panel: #18181b;
--era-surface: #27272a;
--era-text: #e4e4e7;
--era-radius-bubble: 18px;
}