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.

html
<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>
This has to be a classic script tag in the document. The widget reads its configuration from 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.

FieldTypeDescription
data-api-keyrequiredstringYour 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-colorstringAccent 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-titlestringHeader title, and the launcher’s aria-label. Defaults to "AI Assistant".
data-placeholderstringPlaceholder 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-lengthnumberMaximum 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-urlstringAbsolute 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-keystringOpt 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.

html
<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>
FieldTypeDescription
user.idrequiredstringStable identifier for the end user. Scopes sessions, private knowledge, credentials, and tasks.
user.namestringDisplay name, available to the agent.
user.emailstringUser's email, available to the agent.
user.rolestringYour own role label, usable in policies.
user.dataobjectArbitrary key/values the agent can read and that policies match on (user.data.*).
user._tsnumberSigned mode: Unix seconds when the identity was signed. Valid for 300 seconds.
user._sigstringSigned mode: HMAC-SHA256 over the canonical identity. Produced server-side, never in a public env var.
Never compute _sig in the browser. The signing secret is server-side only. See the signed identity guide for the exact canonicalization.
Timezone is automatic. The widget sends the browser's IANA zone with every message as a separate 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.id is set.
  • The GET /v1/project-config feature gate below, which is skipped entirely if window.ERAConfig.user.id is absent at that moment. Set ERAConfig before 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:

javascript
// 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 window
If one does expire, the turn fails with IDENTITY_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)

GET/v1/project-config

This 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.

FieldTypeDescription
api_keyrequiredstringQuery parameter. Your project key, era_.... An unknown key returns 404 with { "detail": "Project not found" }.
bash
curl "https://agentifys.ai/v1/project-config?api_key=era_your_project_key"

Response:

json
{
  "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" }
  ]
}
FieldTypeDescription
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_enabledbooleanReveals 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_enabledbooleanReveals 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_enabledbooleanLets 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_serversarrayserver_id and label for each of those servers. Returned for API callers; the widget itself reads only the boolean above.
name, tagline, logo_colorstringProject 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.
The widget swallows every failure of this call. A 404, a network error, a proxy that does not forward the route — all land in an empty catch: no console message, no retry. Chat keeps working, and the attach button, the My-tasks footer and the Connect prompts simply never appear. If a customer reports that uploads or tasks have vanished, check this request in the network panel first; nothing else will tell you.
The call is also skipped outright when 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.

FieldTypeDescription
ERA.open()functionOpen the chat panel.
ERA.close()functionClose the chat panel.
ERA.sendMessage(text)functionSend a message as the user, as if they typed it. Opens the panel if closed.
ERA.clear()functionStart 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)functionRegister 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()functionOpen 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)functionStart the per-user MCP OAuth flow for a server, from your own button.
ERA.point(target, label?)functionSpotlight 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)functionSame as point() with no label.
ERA.scrollTo(target)functionScroll the element into view without dimming or ringing it.
ERA.clearPoint()functionRemove the spotlight immediately. It also clears itself on scroll, on resize, on the next message, and after a few seconds.
ERA.ask(text)functionOpen 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()functionOpen 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()functionRemove 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.
Call 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.
Teardown. Re-injecting the script tag does not stack listeners: boot calls the previous instance's 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.

FieldTypeDescription
typestringEvent kind: "session", "text", "tool_start", "tool_end", "confirmation_required", "error", or "done".
contentstringOn "text", the incremental chunk of the reply; on "error", the error code or message the server sent.
fullstringOn "text" and "done", the full reply so far, already concatenated for you.
session_idstringOn "session" only: the id of the conversation this turn belongs to. It is the first event of every turn, not only the first turn.
namestringOn "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.
javascript
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?");
});
Write actions still pause for Approve or Cancel inside the widget, and the Stop button always cancels an in-flight reply. Driving the widget with 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.

text
It is in Settings, here:

```era-ui
{"action":"point","target":"invite","label":"Invite a teammate"}
```
FieldTypeDescription
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.
targetrequiredstringA data-era-id token, or any CSS selector.
labelstringShort caption shown beside the ring on "point".

Two things to set up on your side, and the agent knows the block format already:

  1. 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.
  2. 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.
text
## 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
The agent points, it does not click. Nothing on your page is activated on the visitor's behalf. The spotlight is also entirely non-blocking: it ignores pointer events and clears itself on scroll, on resize, on the next message, and after a few seconds, so it can never trap someone. Turn the whole thing off with 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

FieldTypeDescription
--era-accentcolorAccent 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-panelcolorPanel and header background.
--era-surfacecolorAssistant bubble and inner surfaces.
--era-usercolorUser bubble background. Defaults to the accent.
--era-textcolorPrimary text color.
--era-text-dimcolorMuted text (placeholder, close icon, counts).
--era-bordercolorHairline borders and dividers.
--era-input-bgcolorMessage input background.
--era-input-bordercolorMessage input border.
--era-radiuslengthPanel corner radius.
--era-radius-bubblelengthMessage bubble corner radius.
--era-radius-inputlengthInput (and pill) corner radius.
--era-fontstringFont stack for the whole widget.
Set your brand colour on 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)

FieldTypeDescription
::part(launcher)buttonThe floating chat button.
::part(panel)elementThe chat panel container.
::part(header)elementThe panel header bar.
::part(messages)elementThe scrolling message list.
::part(footer)elementThe input footer.
::part(input)textareaThe message input.
::part(send)buttonThe send / stop button.
::part(attach)buttonThe attach-document button.
::part(bubble)elementAny message bubble.
::part(bubble-user)elementUser message bubbles only.
::part(bubble-assistant)elementAssistant message bubbles only.
::part(title)elementThe header title text. Hide it with display:none if you render your own header.
::part(close)buttonThe header close button.
::part(welcome)elementThe empty-state greeting shown before the first message.
::part(option)buttonA quick-reply option button, when the agent offers choices.
::part(banner)elementThe "new scheduled results" notice strip.
::part(tasks)elementThe My-tasks panel (signed mode).
::part(mic)buttonThe dictation microphone button.
::part(speak)buttonThe read-aloud speaker toggle.
::part(ask)buttonThe "Ask AI" pill shown on a text selection.
::part(spotlight)elementThe on-page spotlight overlay used by point().
::part(palette)elementThe 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:

css
#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:

javascript
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);
Variables and parts are the supported contract and survive widget updates. Overriding internal ids/classes (for example #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.

CONTROLS

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.

Assistant×
how do I upgrade my plan?
I can move you to Pro right now. Want me to?
Ask anything…
➤
css
/* 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;
}