GUIDES

Integration walkthrough

Every other page describes one feature. This one carries a single integration from an empty project to a scheduled task running unattended, in the order the work actually happens.

The scenario is fixed and we keep it for the whole page. Acme sells a logistics dashboard. Customers sign in to app.acme.com. We are embedding the assistant for those signed-in users, behind Acme's own server, so that it can look an order up, refund one after the customer approves it, authenticate to Acme's API with each customer's own key, and send a summary every Friday without anyone present.

What we are building

FieldTypeDescription
Transportserver proxyThe browser talks to app.acme.com/era/*. Acme's server holds the project key and forwards to Agentifys.
IdentitysignedThe proxy signs the identity from its own session on every request. The page cannot choose who it is.
Read toollookup_orderA webhook against Acme’s own API. Runs freely.
Write toolissue_refundThe same, plus requires_confirmation, so the customer approves it in chat before it fires.
CredentialsvaultEach customer’s Acme API key, stored encrypted per user and injected into both tools at call time.
SchedulingweeklyThe customer asks for a Friday summary in chat. It runs under their identity with no browser open.

Nine steps. Each one links the reference page that owns the detail, and ends with the thing that most often goes wrong there.

1. Choose the transport, because it decides the allowlist

This is the first decision and the hardest one to reverse, because the allowed-origins list means the opposite thing in each shape.

Browser-direct is the widget calling agentifys.ai from the page. The project key is in your HTML for anyone to copy, and the allowed-origins list is the only thing that limits where that copy can be used. Fill it in with every origin you serve from.

Server proxy is what we are building. The browser only ever talks to app.acme.com, and Acme's server is the only holder of the project key. Those forwarded requests carry no Origin header at all, and an absent origin is read as the empty string, which never matches a non-empty list. So the allowed-origins list must stay empty: one entry refuses every call the proxy makes.

What replaces the allowlist in this shape is your own perimeter. The proxy route sits behind the session check your app already performs, so an unauthenticated visitor cannot reach Agentifys through it at all.

One project cannot do both. If you also want a browser-direct widget on your marketing site with a populated allowlist, that is a second project with a second key. Forwarding an Origin header your own proxy wrote does make the call pass, but the value is then chosen by the caller being checked, so the allowlist is ceremony rather than a control.

The exact matching rules — scheme, host and port, no wildcards, no subdomains — and the list of routes the check runs on are in the API overview.

2. Stand the proxy up

The widget derives every request path from one base. Point it at your server with data-base-url and it will call /era/v1/chat, /era/v1/history, /era/v1/project-config and the rest on your host.

html
<script>
  // The proxy decides who this is. The id here only tells the widget that someone
  // is signed in, so it asks for the per-user features at boot.
  window.ERAConfig = { user: { id: "u_42" } }
</script>
<script
  src="https://agentifys.ai/static/widget.js"
  data-base-url="https://app.acme.com/era"
  data-api-key="era_placeholder"
  data-title="Acme Assistant"
></script>
data-base-url on its own does not take the key out of the page. The widget still sends whatever is in data-api-key as Authorization: Bearer … on every request, and as an api_key query parameter on /v1/project-config. The key stops being exposed only once your proxy overwrites both, at which point the value in your HTML is a placeholder. It cannot be blank: with no data-api-key the widget logs [Agentifys Widget] data-api-key is required and renders nothing.

Chat is the route that needs care. The response is always text/event-stream and it must be written through chunk by chunk.

javascript
// server.js — Node 18+, Express. Every Agentifys call the browser makes goes here.
const ERA    = "https://agentifys.ai"
const KEY    = process.env.ERA_PROJECT_KEY      // era_...  never sent to the browser
const SECRET = process.env.ERA_SIGNING_SECRET   //          never sent to the browser

// Your own session is the ONLY source of identity. Whatever the page sent is discarded.
// signUserContext is the signer from the Identify users guide.
function identityFor(req) {
  return signUserContext(
    { id: req.session.userId, name: req.session.name, role: req.session.role },
    SECRET,
  )
}

app.post("/era/v1/chat", express.json(), async (req, res) => {
  const upstream = await fetch(ERA + "/v1/chat", {
    method: "POST",
    headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json" },
    // message, session_id and client_tz pass through. The identity does not.
    body: JSON.stringify({ ...req.body, user_context: identityFor(req) }),
  })

  res.status(upstream.status)
  res.setHeader("Content-Type", upstream.headers.get("content-type") || "application/json")
  res.setHeader("Cache-Control", "no-cache")
  res.setHeader("X-Accel-Buffering", "no")
  res.flushHeaders()
  // Write each chunk through as it arrives. Collecting the body first turns a
  // streaming reply into a long pause followed by a wall of text.
  for await (const chunk of upstream.body) res.write(chunk)
  res.end()
})
Agentifys answers chat with Cache-Control: no-cache and X-Accel-Buffering: no. Forward both. Do not gzip the stream, raise your idle timeouts above your longest realistic turn, and expect no heartbeat between events — the connection is silent while a tool runs. The full list of proxy requirements is on the chat reference.
Failures that happen before the stream opens — bad key, origin, rate limit, budget, identity, session ownership — come back as ordinary JSON with a non-200 status and no SSE at all. A proxy that pipes the body without looking at the status turns every one of them into a blank reply.

3. Turn signed identity on and keep the secret in the proxy

In the console, under Settings then Identity, switch the project from open to signed. The signing secret is minted for you the first time that switch is saved; there is no separate generate step. Copy it into the proxy's environment next to the project key. Those two values are the whole set of things that must never reach a browser.

Signed identity is not optional for this integration. The vault, per-user private documents, per-user MCP OAuth and every scheduling route refuse an open-mode project outright with SIGNED_IDENTITY_REQUIRED.

The signature is an HMAC-SHA256 over a canonical JSON of the identity plus a Unix timestamp _ts, and it is valid for 300 seconds. The recipe, including the non-ASCII escaping that an Arabic or emoji-bearing name depends on, is in Identify users. Use it as written.

The proxy shape quietly removes the 300-second problem. Because identityFor(req) runs as each request is forwarded, every call carries a signature a few milliseconds old. A browser-direct install has to re-sign on a timer and reassign window.ERAConfig.user instead, or a page left open overnight starts answering Your session expired — please reload the page.
The failure people reach for last is clock skew. IDENTITY_SIGNATURE_EXPIRED compares _ts against the server clock in both directions, so a proxy running five minutes ahead fails exactly like one running five minutes behind. Check the clock before you start debugging canonical JSON.

4. Put the identity in the right envelope per route

Now that the proxy substitutes the identity rather than passing it through, it has to know where each route carries it. The location is not guessable from the HTTP method, and there is no fallback: a route reads exactly one place, and an identity sent anywhere else is treated as absent.

These are the routes this integration touches. The widget calls every one of them except the vault, which only your backend calls.

FieldTypeDescription
POST /v1/chatbodyReplace user_context in the JSON body; leave message, session_id and client_tz alone.
POST /v1/feedbackbodySame: user_context in the body, alongside rating and trace_id.
GET /v1/historyheaderX-Era-User, holding the same JSON object URL-encoded.
GET /v1/scheduled-tasksheaderX-Era-User, same encoding.
POST /v1/scheduled-tasks/seenbodyuser_context in the body.
PATCH /v1/scheduled-tasks/{task_id}bodyuser_context in the body, alongside status.
DELETE /v1/scheduled-tasks/{task_id}queryuser_context as a query parameter, URL-encoded JSON. There is no body to put it in.
PUT /v1/credentialsbodyuser_context in the body, alongside name and value. Called from your backend, not the widget.

Always URL-encode the X-Era-User value. A raw JSON header is accepted, but a name containing a non-Latin-1 character is rejected by the browser before the request leaves — and a signature over a name you had to strip is a signature that will not verify. The full matrix, every route included, is on the API overview.

A missing identity in signed mode is not a validation error you can read at a glance. It is a 401 with IDENTITY_SIGNATURE_REQUIRED, the same code you get for a signature you forgot to compute. If one route in your proxy fails that way while the others work, the envelope is wrong — look at this table before you look at your signer.

5. Add the read tool

A tool is an HTTPS endpoint you already own. Register lookup_order against Acme's own API, describe it for the model, and give it a JSON schema for its arguments. The model's arguments arrive nested under input, so your handler reads body.input.order_id, and the caller's profile arrives alongside as body.user.

json
{
  "name": "lookup_order",
  "display_name": "Look up an order",
  "description": "Look up the status of one of this customer's orders by its id.",
  "enabled": true,
  "input_schema": {
    "type": "object",
    "properties": { "order_id": { "type": "string" } },
    "required": ["order_id"]
  },

  "executor_type": "webhook",
  "webhook_url": "https://api.acme.com/orders/lookup",
  "webhook_method": "POST",
  "webhook_headers": { "X-Api-Key": "{{user.creds.acme_api_key}}" },

  "requires_confirmation": false
}

Return application/json with a 2xx. Signal a business-level failure — no such order — as a 200 with an error field in the body, not as a 404: a 4xx never reaches the agent, it becomes TOOL_UNAVAILABLE and your response body is discarded. The request and response shapes, the retry and idempotency contract, and the SSRF rules that apply to every webhook URL are in Connect tools.

executor_type defaults to "mock", and a mock tool makes no outbound request at all. Setting webhook_url and forgetting the type is the quiet failure: nothing errors, the agent answers confidently, and the numbers are invented. Two tells in a tool result give it away — "mock": true, and any declared parameter the model omitted coming back as the literal string <order_id>.

6. Add the write tool and close the confirmation loop

issue_refund is the same shape with one field changed. Setting requires_confirmation to true is what makes it a write as far as the platform is concerned, and that single flag governs two separate gates: the in-chat approval now, and what a scheduled run is allowed to touch later.

json
{
  "name": "issue_refund",
  "display_name": "Issue refund",
  "description": "Refund one of this customer's orders. Confirm the amount first.",
  "enabled": true,
  "input_schema": {
    "type": "object",
    "properties": {
      "order_id": { "type": "string" },
      "amount":   { "type": "string" }
    },
    "required": ["order_id", "amount"]
  },

  "executor_type": "webhook",
  "webhook_url": "https://api.acme.com/orders/refund",
  "webhook_method": "POST",
  "webhook_headers": { "X-Api-Key": "{{user.creds.acme_api_key}}" },

  "requires_confirmation": true
}

When the model calls it, nothing runs. Agentifys holds the call against the session, emits a confirmation_required event carrying the display_name, the exact input, and the two literal tokens that resolve it, and ends the turn. The embedded widget draws Approve and Cancel for you. If you render your own transcript, you send the token back as the next user message on the same session_id.

json
// The event that ends the turn. Read confirm/decline off it; do not hardcode them.
{
  "type": "confirmation_required",
  "id": "toolu_01B4",
  "name": "issue_refund",
  "display_name": "Issue refund",
  "input": { "order_id": "1043", "amount": "42.00" },
  "confirm": "__era_confirm__",
  "decline": "__era_decline__"
}

Everything the stream can emit, in what order, and what each resume produces is on the chat reference.

The hold is matched by session, not by the tool-call id. The resume message carries no reference to what it approves, and the server runs whatever that session is holding. A session holds at most one pending action and a newer one silently replaces the older, so a UI that can show two approvals at once can release the wrong write.
Your proxy must keep session_id stable across the two turns, which the sample above does by passing the body through untouched. Rewriting or dropping it means the approval lands on a new session that holds nothing, and the refund is never issued — with no error anywhere.

7. Give each customer their own credential

Both tools authenticate as the customer, not as Acme, so the key cannot live in a static header. Store it once per user in the vault and reference it by name. The write is server to server: your backend sends the signed identity, the name and the value.

PUT/v1/credentials
javascript
// Your own settings page: the customer pastes their Acme API key into your UI,
// your backend puts it in the vault. The browser never touches /v1/credentials.
app.post("/settings/acme-key", express.json(), async (req, res) => {
  const r = await fetch(ERA + "/v1/credentials", {
    method: "PUT",
    headers: { Authorization: "Bearer " + KEY, "Content-Type": "application/json" },
    body: JSON.stringify({
      user_context: identityFor(req),
      name:  "acme_api_key",       // the exact name you reference in the tool header
      value: req.body.apiKey,
    }),
  })
  // The response is a masked hint, never the value: { name, secret_hint }.
  res.status(r.status).json(await r.json())
})

All three credential methods skip the origin check, because they are designed to be called from a server that sends no Origin header. They still require signed identity, which is what binds the secret to a verified user rather than to a claim. Names, size limits, the per-user cap and the write throttle are in the vault guide.

Once it is stored, {{user.creds.acme_api_key}} in the tool's webhook_headers resolves to the decrypted value at call time. The substitution happens after the model has chosen to call the tool, so the secret is placed in the outbound request and never enters the prompt or the transcript. Templating resolves in webhook_headers and webhook_params only — not in webhook_url, and not in the arguments the model produced.

A webhook placeholder fails open. A misspelled name, a user who has not connected their key yet, or an identity that was never verified all collapse the token to an empty string and the request goes out anyway — your API receives an empty X-Api-Key and answers 401. That 401 is a 4xx, so it is not retried, and the agent is simply told the tool is unavailable. Check the value you receive server-side rather than assuming a template that was configured is a template that resolved.
Credentials hydrate only when the identity on that turn was actually HMAC-verified. In an open-mode project, or in the console playground where the identity is typed rather than signed, every {{user.creds.*}} resolves to an empty string. A tool that works in production and returns 401 in the playground is usually this, not a broken key.

8. Turn scheduling on — both switches

In Settings, under Scheduling, enable it. Four reserved tools become available to the model — schedule_task, list_scheduled_tasks, update_scheduled_task and cancel_scheduled_task — and the customer schedules by asking, not by filling in a form.

text
"Every Friday at 5pm, summarise my open orders and flag anything
 that has been stuck for more than a week."

That much gets you a run that can read, summarise and report. It cannot refund anything, and this is the second switch people miss. A scheduled run may only use a write tool that was pre-authorised on that task, and the pool it can be drawn from is the project's Tools allowed to run unattended list, which starts empty. Tick issue_refund there first; only then can the agent attach it to an individual task.

FieldTypeDescription
Project switchenabledOff by default. While it is off the reserved tools are not offered and due tasks are skipped rather than run.
Project poolwritable_toolsEmpty by default. The console only offers tools that are marked requires_confirmation — nothing else can be added to it.
Per-task grantallowed_write_toolsWhat the agent pre-authorises on one task. A name outside the project pool is refused when the task is created.
IdentitysignedRequired. A project in open mode never fires a due task, because the caller-supplied id there is forgeable.
requires_confirmation is the only thing that marks a tool as a write, and the unattended filter removes only confirmation-gated tools that were not pre-authorised. A tool that writes but was left at requires_confirmation: false is therefore offered to every scheduled run, unsupervised. The allowed list is not a way to rein it back in — the console only offers confirmation-gated tools for it, and the filter only ever removes those. The flag is the control. Set it on every write tool, including the ones you never intend to schedule.
{{user.auth_token}} is always empty in a scheduled run. The scheduler stores only the user id plus a snapshot of name, email, role and company — there is no live request to take a token from, and {{user.data.*}} is gone with it. Vault credentials are the exception and are decrypted fresh on every run, which is exactly why both Acme tools authenticate with {{user.creds.acme_api_key}} and work unattended without a change.

What a run carries, how the timezone is resolved, the five ways a run ends, and the My-tasks panel are in the scheduling guide.

9. Test these nine things before you launch

Each of these fails quietly rather than loudly, which is why they are worth doing deliberately rather than waiting to notice.

FieldTypeDescription
The allowlist matches the shapeemptyFor a proxy install, confirm the allowed-origins list is empty. One leftover entry from a browser-direct experiment refuses every call the proxy makes, with ORIGIN_NOT_ALLOWED and a 403.
The mock check"mock": trueTrigger each tool once and read the raw result. "mock": true or a literal <order_id> means executor_type never left "mock" and the agent has been inventing answers.
The credential checkserver-sideLog the credential header your own endpoint receives for a user who has not yet connected their key. It should arrive empty, and your endpoint should answer 401 rather than falling back to a default account.
The held write, both waysapprove + cancelApprove one refund and cancel another. Cancelling emits one text event and nothing runs; approving emits tool_start, tool_end and a one-line summary, with no stats event.
Two users, one browserERA.clear()Sign out, sign in as someone else, send a message. The stored session id is keyed by project, not by user, so without ERA.clear() on sign-out every message comes back "Session not found."
Clock skewdeliberateMove the proxy clock ten minutes and confirm you see IDENTITY_SIGNATURE_EXPIRED rather than a generic failure. Knowing what it looks like once saves an afternoon later.
A non-ASCII namesignatureSign an identity whose name contains non-ASCII characters and send one message. A signer that skips the \uXXXX escaping passes every ASCII test and fails on your first international customer with IDENTITY_SIGNATURE_INVALID.
Rate limits20/min, 500/dayA new project ships with those enabled, and they are project-wide, not per customer. The per-user chat throttle reuses the same two numbers, so with more than one active user the project ceiling is what you hit first. Size it for your traffic before launch, not after.
A real scheduled runend to endSchedule something two minutes out and read the result text, not just the run status. A run with no write tools finishes successfully and reports what it could not do — the missing write appears only in the prose.
The vault, per-user private documents and scheduled tasks are all PostgreSQL-backed, and only the upload announces it. Without DATABASE_URL a private upload returns an error, but PUT /v1/credentials answers 200 with a plausible secret_hint while storing nothing, and the agent reports a task as scheduled that was never written and will never run. Test these three against a Postgres deployment or you are testing nothing. See Self-hosting.
One thing worth deciding now rather than discovering: switching to signed identity also switches on the widget's attach button, because per-user uploads are gated on signed mode with no separate toggle. In the proxy shape you own the /v1/project-config response, so you either proxy the four /v1/knowledge routes or return kb_uploads_enabled: false as the sample in step 2 does. Leaving it on without a proxy route gives the customer a paperclip that fails.