Daemon
How to call an agent from your own code and get its answer back with no chat interface involved.
Overview
The Daemon calls a Flourish agent and waits for its answer. The agent receives a message, reasons over it using the tools and trusted resources configured on its deployed version, and returns a text response.
You call it over HTTP from a cron job, a Lambda, an automation tool, or an Apex callout. In Salesforce, a Flow action makes the same call with no code. See From a Flow.
There are two conversation modes:
- Stateless: each call is independent. No history is kept between calls.
- Stateful: the agent keeps a conversation going across calls using a Conversation ID. Use this for multi-turn automations where the agent needs to remember what was said before.
Note: an agent's turn is limited to 30 minutes unless its configuration says otherwise. A caller that stops waiting sooner, as any Salesforce callout does at 120 seconds, never sees the answer to a longer turn. If your caller can't wait that long, break the work into smaller steps. See How long a call can take.
Over HTTP
POST https://agentplatform.toflourish.org/api/agents/{agent_id}/invoke
Content-Type: application/json
{agent_id} is the agent's Agent ID. See Finding an agent's ID.
The Agent Platform has its own host, agentplatform.toflourish.org. It isn't on api.toflourish.org, which serves other Flourish solutions.
Headers
| Header | Required | Purpose |
|---|---|---|
fl-api-org | ✅ | Identifies your organization. |
fl-api-token | ✅ | Your API token. |
fl-api-env | To use another environment | The environment to run against, such as prod or dev. Without it, the agent runs against prod. |
fl-api-user | To call as a person | A person's Agent Platform user name, sent with that person's own token. See Calling as a person. |
fl-share-key | To run an agent shared with you | A share key, with the three above or instead of them. See Running an agent shared with you. |
Idempotency-Key | If you retry | A stable value tied to the logical request. See Retries. |
See Authentication for the shared scheme. Your token also needs chat permission on the agent you're calling, or the call returns 403.
Request body
| Field | Type | Purpose |
|---|---|---|
message | string | The message to send, as a person would type it in a chat. |
stateful | boolean | true to start or continue a remembered conversation. Defaults to false. |
conversation_id | string | null | The ID from a previous stateful call. null starts a new conversation. |
host_context | object | null | Structured data passed alongside the message for the agent to reason over. See Passing Host Context. |
additional_instructions | string | null | Extra instructions for this call only, added to the agent's configured prompt. Use it for something specific to this caller that doesn't belong in the agent itself, such as an output format. |
group_key | string | null | Names which memory the agent uses for this call. See Choosing which memory to use. |
origin | string | "user", the default, for a message a person wrote. "host" for one your system sends, which the agent may choose not to answer. See A message from your system. |
client | string | null | Your integration's name, such as slack or case-summaries. See below. |
{
"message": "What are the top 5 accounts by revenue?",
"stateful": false,
"conversation_id": null,
"host_context": null,
"additional_instructions": null,
"group_key": null,
"origin": "user",
"client": null
}
client names your integration. A conversation keeps the name of whatever started it, and so does each message your integration sends into it, so Greenhouse can show the people reading a conversation where it and its messages came from. A call that continues a conversation leaves the name it started with. The name is a label and grants nothing. It takes up to 64 letters, digits, and . _ : -, and it starts with a letter or digit. Name it when people will read the conversations your integration starts.
Response
Returns 200 with:
| Field | Type | Meaning |
|---|---|---|
response | string | The agent's text reply. Empty when responded is false. |
conversation_id | string | null | The conversation to pass back on the next stateful call. |
terminated_reason | string | null | null on a normal finish. Otherwise one of the reasons below. |
failure_category | string | null | On a provider_error, what kind of failure it was. See Sending a failed call again. |
nothing_ran | boolean | true when the turn failed before the agent did anything. See Sending a failed call again. |
blocked | boolean | true when the agent's safety policy stopped the reply. Then response is a placeholder. |
compose_failed | boolean | true when the agent did its work but its reply couldn't be written. See below. |
responded | boolean | false only when the agent chose not to answer a message sent with origin set to "host". |
files | array | null | The files the agent delivered on this call. See Files the agent delivers. |
usage | object | What the call spent. See What a call cost. |
{
"response": "Here are the top 5 accounts by revenue:\n\n1. Acme Corp - $2.3M\n...",
"conversation_id": "uuid-if-stateful-new",
"terminated_reason": null,
"failure_category": null,
"nothing_ran": false,
"blocked": false,
"compose_failed": false,
"responded": true,
"files": null,
"usage": {
"tokens_in": 865,
"tokens_out": 192,
"cache_read_tokens": 47453,
"cache_write_tokens": 0,
"estimated_cost": 0.016791,
"cost_by_basis": {"billed": 0.016791}
}
}
Other fields can appear. Ignore any you don't use.
Check terminated_reason, blocked, and compose_failed before trusting the text. A turn that hit max_iterations still returns whatever partial answer it had composed, and it reads like a complete one. A caller that writes it to a record or emails it to a customer has no other way to tell the difference. This matters most when one agent consults another. Without the check, a degraded reply gets passed on as though it were authoritative.
When a reply is partial
The list of reasons grows, so treat any value you haven't seen as a partial reply, too.
terminated_reason | What happened | Retrying |
|---|---|---|
max_iterations | The agent needed more steps than one call allows. | Split the work into smaller calls. The same call stops in the same place. |
loop_detected | The agent repeated itself. | Same. Look at the instructions rather than calling again. |
turn_timeout | The agent ran out of time and answered early with what it had. | Split the work into smaller calls. |
llm_error | The model call failed. | Don't retry automatically. See below. |
provider_error | The agent's own AI provider, on its own key or a Flourish seat, failed, and the platform's retries didn't get past it. | Depends on failure_category. See below. |
compose_failed isn't one of those reasons. The turn finished. The agent did its work and any tools it used ran, but the last step, writing the reply, failed. response is then the agent's own summary of what it did or a short notice that the work happened. It isn't an answer. Don't send the call again as though nothing happened. The work is done, and a second call does it again.
Sending a failed call again
A turn that ends in llm_error or provider_error says whether the same call is safe to send again and whether it's likely to work.
nothing_ran is true when the turn failed before the agent did anything, so sending it again repeats no work. When it's false, the agent may already have used its tools, and a second call could do the same work twice.
failure_category says what kind of provider_error it was:
failure_category | What happened | Sending it again |
|---|---|---|
transient | The provider was overloaded or briefly down. | Likely to work after a pause. |
quota | The provider's rate limit. | Works once the limit recovers. |
credential | The provider refused the key. | Never works. Someone has to fix the agent's AI provider settings. |
other | Anything else. | Don't. |
Treat a value you haven't seen as other.
Retry automatically only when nothing_ran is true and failure_category is transient, and wait a little first. The platform has already tried three times. Anything else goes to a person or waits for the next scheduled run.
Example request
- curl
- Python
- Apex
curl -X POST https://agentplatform.toflourish.org/api/agents/YOUR_AGENT_ID/invoke \
-H "Content-Type: application/json" \
-H "fl-api-org: YOUR_ORG" \
-H "fl-api-token: YOUR_TOKEN" \
-H "fl-api-env: prod" \
-H "Idempotency-Key: case-0013X00000AbCdEF" \
-d '{
"message": "Summarize this case for the account team.",
"stateful": false,
"host_context": {
"record_id": "0013X00000AbCdEF",
"account_name": "Acme Corp"
}
}'
import requests
resp = requests.post(
"https://agentplatform.toflourish.org/api/agents/YOUR_AGENT_ID/invoke",
headers={
"fl-api-org": "YOUR_ORG",
"fl-api-token": "YOUR_TOKEN",
"fl-api-env": "prod",
"Idempotency-Key": "case-0013X00000AbCdEF",
},
json={
"message": "Summarize this case for the account team.",
"stateful": False,
"host_context": {
"record_id": "0013X00000AbCdEF",
"account_name": "Acme Corp",
},
},
timeout=1980,
)
resp.raise_for_status()
data = resp.json()
if data["blocked"] or data["compose_failed"] or data["terminated_reason"]:
raise RuntimeError(f"Not a finished answer: {data['terminated_reason']}")
print(data["response"])
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Flourish_Agent_Platform/api/agents/'
+ agentId + '/invoke');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('fl-api-org', orgId);
req.setHeader('fl-api-token', token);
req.setHeader('fl-api-env', 'prod');
req.setHeader('Idempotency-Key', caseId);
req.setTimeout(120000);
req.setBody(JSON.serialize(new Map<String, Object>{
'message' => 'Summarize this case for the account team.',
'stateful' => false,
'host_context' => new Map<String, Object>{ 'record_id' => caseId }
}));
HttpResponse res = new Http().send(req);
Map<String, Object> body =
(Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String answer = (String) body.get('response');
Store the host and token in a Named Credential rather than in the class, so the token never sits in source control.
Errors
| Status | Meaning | What to do |
|---|---|---|
400 | The agent is set to Per person memory, and the call named no person. | Call as the person. See Calling as a person. |
401 | The credentials were refused. That includes a person's user name sent with a token that isn't theirs, and a share key that was revoked or has expired. | Fix the headers. Retrying won't help. |
402 | A monthly spend limit has been reached, either the organization's or this agent's own. | It holds for the rest of the month, so don't retry. Raise the limit, or wait for the reset at the start of the next month, UTC. The agent's own limit is in its Cost settings. |
403 | The token lacks chat permission on this agent. | Fix the grant. Retrying won't help. |
404 | No agent with that ID that you can reach. An agent in another organization that isn't shared with you answers exactly as one that doesn't exist. A stateful call naming a conversation that isn't this agent's gets it, too. | Check the ID, and check that the agent is shared with you. |
409 | A request with the same Idempotency-Key is already running. | Treat it as already in flight and wait for the original. |
429 | A conversation has passed 200 calls in 2 minutes. | Pause and retry. Only stateful callers reusing one conversation_id can reach it. |
504 | A step of the turn ran long past the agent's time limit, and the server canceled it. | Don't retry. See below. |
How long a call can take
A turn is limited by the agent's own turn limit, 30 minutes by default. At the limit, the agent normally stops itself between steps, and you get a 200 with the partial answer it composed and terminated_reason set to turn_timeout. Only when a single step runs more than two minutes past the limit is the turn canceled outright. Then you get a 504, and nothing is kept.
Set your client's read timeout to the limit plus about three minutes, 33 minutes for an agent on the default, or the partial answer loses the race to your own timeout. The Python example above uses 1,980 seconds.
A timeout on your side, or a 500, doesn't mean nothing happened. The agent may have used its tools, or finished, after your client stopped waiting. Before calling again for anything that changes data, check whether the work was done.
Apex and Flow can't wait that long. Salesforce caps any callout at 120 seconds, so a Salesforce caller sees a timeout on a turn that runs longer. The agent carries on, and the call is charged. Keep work that is called from Salesforce well inside two minutes.
A script calling in from a scheduled run has a tighter bound that has nothing to do with the turn. Its connection is dropped after about 350 seconds with nothing passing, so a call that waits longer never receives its answer. Keep each such call under five minutes, or make several.
Retries and idempotency
Every call costs money and can take minutes. If the same call arrives twice while the first is still running, you get two turns and two charges for one piece of work. That can come from a caller that retries on a fault, a screen someone double-clicks, or a scheduled job overlapping its previous run.
If your caller retries at all, send an Idempotency-Key. Use a value that identifies the request rather than the attempt, such as the source record ID. A concurrent duplicate carrying the same key gets 409 rather than starting a second turn. Handle it by doing nothing. The first call is still working, and its result is the one you want. A key that is unique per attempt is the same as no key at all.
Without the header, every request runs independently. That's on purpose, so a bulk run of many records through one prompt doesn't collide with itself.
Don't retry a 504 automatically. The work was canceled for being too heavy, so an immediate retry runs the same heavy work again while the first attempt may still be winding down. For work that takes longer, use a chat session or a scheduled task instead.
A client that retries on its own timeout, against a stateful conversation that grows with each attempt, is the pattern that produces runaway cost. If you retry, send an idempotency key. Better still, acknowledge the request immediately and do the work later.
Files the agent delivers
When the agent hands over a file, such as a report or a spreadsheet, it's listed in files, which is null when there are none. A file from another agent this one consulted is listed, too. Each entry carries:
| Field | Meaning |
|---|---|
filename | The file's name. |
content_type | Its type, such as text/csv. |
size | Its size in bytes. |
conversation_id | The conversation that holds it. |
The response carries no link. Ask for one with the same headers, URL-encoding the file name:
GET https://agentplatform.toflourish.org/api/conversations/{conversation_id}/outputs/{filename}
It returns a url to download the file from, which works for an hour (expires_in is in seconds). Ask again whenever you need a fresh one. Use the conversation_id on the file's own entry. A stateless call returns no conversation ID at the top of the response, but its files are still there.
What a call cost
usage reports what the call spent. That includes any agent it consulted.
| Field | Meaning |
|---|---|
tokens_in | Fresh input only: what was neither read from nor written to the prompt cache. On its own it understates what the model read. |
tokens_out | What the model wrote. |
cache_read_tokens, cache_write_tokens | Input read from and written to the prompt cache. The model read tokens_in and both of these in all. |
estimated_cost | US dollars at the AI provider's list price, whoever pays. |
cost_by_basis | The same dollars split by who pays, listing only the parts that apply: billed through Flourish, seat on a Flourish seat, and customer on the agent's own AI provider key. |
Read cost_by_basis before treating estimated_cost as a charge. A seat amount is charged to no one. It's what the same work would have cost. A customer amount is billed by your own AI provider, not by Flourish. One call can carry more than one part.
What the HTTP path doesn't do
- No file uploads. Attaching files to a conversation needs a chat session. Files the agent delivers do come back. See Files the agent delivers.
- No progress updates. Anything the agent would stream as it works is discarded. You get the final response only.
- Actions arrive as text. If the agent is configured with actions, they're appended to
responseas:::action ... :::blocks for you to parse, not returned as structured fields.
Choosing which memory to use
An agent set to Shared across everyone doesn't have to keep a single memory for the whole organization. group_key names which memory this call should use, such as a class, an account, a case, or a household.
Nothing is set up in advance. The same name always reaches the same memory, and a name you have never used starts one. So the name can come straight from your own data, such as an account number or a case number.
It takes 1 to 128 characters of letters, digits, and . _ - @. Anything else is rejected.
Two things to know:
- It's scoped to your organization automatically. Two organizations that both use
class-1never touch the same memory, and there's no way to name another organization's group. - Agents set to No memory ignore it. There's no memory to address, so passing it changes nothing.
group_key and conversation_id answer different questions. conversation_id continues one specific thread. group_key chooses whose long-term memory the agent draws on. You can pass both.
Stateless vs. Stateful
Stateless (default)
Each call is independent. The agent has no memory of previous calls. Use this for one-shot tasks like summarizing a record, classifying text, or writing a draft email.
Set stateful to false and leave conversation_id empty.
Stateful
The agent keeps conversation history across calls. The first call starts a new conversation and returns a Conversation ID. Pass that ID back on the next call, and the agent picks up where it left off.
First call: set stateful to true and leave conversation_id empty. The response includes a new Conversation ID.
Later calls: set stateful to true and pass the stored conversation_id. The agent continues with full context from earlier turns.
Store the Conversation ID somewhere that outlives the call, such as on the record the work is about, so it survives across separate runs, batch jobs, or time-triggered automations.
A stateful conversation grows with every turn, so it costs more per call as it gets longer. Start a fresh conversation when the topic changes rather than extending one forever.
Passing Host Context
Host Context lets you pass structured data to the agent alongside the message. The agent receives it as more context it can draw on when forming its response.
Any valid JSON object is accepted. For example, you could pass record data so the agent can reason about a specific record:
{
"record_id": "0013X00000AbCdEF",
"account_name": "Acme Corp",
"annual_revenue": 5000000,
"owner": "Jane Smith"
}
Send it as a JSON object in host_context.
A message from your system
Set origin to "host" when the message comes from your system rather than from a person, such as a scheduled check-in like Here are today's open cases. The agent reads it as a note from the system it works in, and it may decide there is nothing to say. Then responded is false, response is empty, and no reply is added to the conversation.
That leaves "stay quiet unless something changed" to the agent. You don't have to ask it for a reply every time and search the reply for a sign that something should be passed on. Check responded before you send response anywhere.
With the default, "user", the agent always answers, and responded is always true.
Calling as a person
A call acts as your organization unless it names a person. To act as one person, send their Agent Platform user name in fl-api-user and their own token in fl-api-token. That's the same user name and token they sign in to Greenhouse with or enter in the My Access component in Salesforce.
fl-api-org: YOUR_ORG
fl-api-user: jsmith
fl-api-token: JSMITH_TOKEN
fl-api-env: prod
- An agent set to Per person memory needs it. Called as your organization, such an agent refuses with
400because there's no person whose memory to use. Called as the person, it uses theirs. - The token has to be the person's. Sent with your organization's token instead, the call is refused with
401if the person has a token of their own. Otherwise, it runs as your organization. - The call can do what the person can. Without chat permission on the agent, it returns
403. - Someone who belongs to no organization leaves out
fl-api-organd sends onlyfl-api-user,fl-api-token, andfl-api-env.
Running an agent shared with you
Another organization can let yours run one of its agents and give you a share key for it (see Share Keys). Send the key in the fl-share-key header. It's a credential like your token. Keep it on your server and never in a web page or a URL.
| What you send | What runs |
|---|---|
fl-share-key with your own fl-api-org, fl-api-token, and fl-api-env | The shared agent, on your organization's data, billed to your organization. |
fl-share-key alone | The shared agent, as the organization it was shared with. |
Send your own headers as well when the agent should work with your organization's own systems, such as your Salesforce. The key decides what you can do with the agent. Your headers decide whose data it works on and who pays.
A key that was revoked or has expired is refused with 401. An agent in another organization that you can't reach answers 404 exactly as one that doesn't exist would.
Tips
Keep messages focused. The clearer and more specific your message, the better the agent's response. If the task depends on record data, include the relevant fields in Host Context rather than putting them in the message as raw text.
Use stateful mode for multi-step tasks. If you need the agent to gather information across several steps in a process, a stateful conversation lets it build up context rather than starting over every time.
Watch the timeout. Complex agent tasks, especially those with many tool calls, large datasets, or code to run, can take a long time. Anything near your caller's own wait is a reason to split the work across separate calls or narrow the agent's task.