Mangrove
Run Mangrove to process images, research the web, analyse documents, or transcribe your meetings.
GreenPT's Mangrove works through a task using web search, page scraping, document and image analysis, or audio transcription, then answers your question. A run starts asynchronously and returns an ID you poll or stream for progress and the final answer.
Before we start
More than a year ago Thomas Ptacek of Fly.io wrote:
[To grasp some] technologies, you need to get your feet on the pedals first. Get on this bike and push the pedals.
By then, we were already pushing the pedals for a long while. Since then, we've built many agents, for ourselves and for our partners. As our work changed, we've become proverbial bicycle makers :-)
We're nerds, so we're having fun getting on this bike, pedaling, and taking our bike apart in the weekend and cleaning up, and putting it back neatly... And it does indeed help grasping the technology.
We're also nerds that want to solve problems of our customers and partners. While we're always happy to have you over at "our shop" and tinker with things together... we think building agents must be easy for you. So we've built something for you!
What are agents?
By now you're most likely familiar with the concept of an "AI chat" (or "LLM completion"). This generally looks like so:
User: Hey, are you naturally intelligent?
Agent: No, I'm artificially intelligent.An "agent" is essentially this dialogue, but carried on by the system itself. In this made up example, to see if it knows what intelligence means, and what naturally means in that sense, and so on... So we're talking about "agents" because they can autonomously decide whether to look something up, and whether more work makes sense.
What makes an agent capable is the tools it has, the tools that a developer gives to it. (Our example was a bit too simplified. A capable LLM wouldn't need to use tools or extra capacities to know what intelligence means.) For instance, a model can search internet, or transcribe audios, or generate documents for you... given that a software developer implemented these and integrated them with the agent.
We're enthusiastic about this technology, so we'd like to make it easier for you to do this.
Getting started
- First, get a free API key with 5 Euros on us here.
- Send a screenshot of your phone's Screen Time summary and ask what to cut back on:
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "question=What should I cut back on?" \
-F "file=@screen-time.png"Voila! Poll or stream the returned agentId for your answer.
Playground
Try it without leaving this page. Save your API key, ask a question, and optionally attach an image, a document, or a recording — or paste a link to one. The run picks up whichever tool the question needs. Events stream in live, exactly as they would over SSE in your own client.
Each Run Mangrove press is one run, standing on its own — the playground does not thread your questions together, so a follow-up here needs the earlier context restated in the question, exactly as it would in your own client. See One run, one exchange.
Mangrove is in beta; the playground tells you when it is not switched on for this environment.
Concepts
The general flow for using GreenPT's Mangrove is:
- Send your question to Mangrove — you get back an agent ID.
- Hold on to that ID. It is how you follow the run's progress and read its answer.
- Mangrove works through the task and answers. If it needs something only you can provide, it says so and stops.
- Want to take it further? Send another question — that starts a new run.
- Repeat :-)
Inside a run
Every run moves through the same phases, and the event stream shows them as numbered steps:
- A forced step, when the request carried something to read. A recording
is transcribed, and an image is looked at, before the model sees your
question — it never has to decide to do either. A document URL is the
exception: the model calls
document_parseitself, for the reason in Case 2. - The agentic loop, where the model calls whichever tools it decides it needs, over at most 20 rounds.
- A final check, where the model is asked whether its answer is complete and whether a tool it has not used would close a gap. It either uses that tool and answers again, or restates the answer it already had.
The final check is why a run emits its answer twice — see Reading a stream without duplicating the answer.
One run, one exchange
Step 4 is worth spelling out, because it is where Mangrove differs from a chat
endpoint you may be used to: a run is a single exchange, not a
conversation. Every run starts from a blank slate and sees only the
question you send it. Nothing on our side threads two runs together, and no
run can be resumed once it has finished — including one that finished by
asking you for something (see Handling
awaiting_input).
The agent ID does not change that. It keeps the run readable until the
expiresAt timestamp the run reports, so you can poll it, stream it, or come
back for the answer later. Reading a run is not resuming it.
Asking a follow-up
A follow-up is a new run, so carry whatever context it needs into the
question — most simply, by quoting the part of the previous answer you want
built on:
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"question": "Earlier I was told: \"EU grid carbon intensity averaged 158 gCO2e/kWh in 2026, down 16% from 189 gCO2e/kWh in 2025.\" How does that compare with 2024, and what drove the difference?"
}'Quoting is not only a convenience — it is what keeps a follow-up cheap. Mangrove, handed the earlier finding, does not have to go looking for it again; asked "and how does that compare with 2024?" on its own, it has to rediscover the whole thing, and you pay for that work a second time (see Billing).
Saved values are a different mechanism
Asking Mangrove to remember something — "save this figure as
grid_intensity_2026" — is not conversation history. It writes a value
under a name, scoped to your account, that you can ask for by name on any
later run, and Mangrove decides to reach for it the same way it decides to
search the web. Use it for figures you want to compare across runs; quote
the previous answer for the thread of a discussion.
Feedback about API? Join our Discord and let us know!
Billing
A run consumes API credits from the account whose key created it, priced as the sum of what the run actually uses:
- Reasoning — every chat completion Mangrove makes, including the run's
final check, is billed per input and output token. Mangrove reasons with
qwen3-235b-a22b-instruct-2507. - Image analysis — a
visionstep is a completion of its own, billed at the rate forpixtral-12b-2409. - Transcription — the
audio_transcribestep is billed per second of audio at the speech-to-text rates. - Tool calls —
web_search,web_crawl,document_parse,save_data, andsave_filecarry no charge of their own.
Both model rates are on the pricing page.
What this means in practice:
- Cost varies per run. Mangrove decides how many steps a task needs, so
two runs with the same question can cost different amounts, and a run's
cost is unknown when it is accepted — the
X-Credits-Remainingheader on the202response reflects the balance before the run, not the run's cost. - A spent balance rejects new runs, not reads.
POST /v1/agents/runsreturns402 Payment Requiredwhen the balance is zero or below. Polling and streaming existing runs keep working, so an outcome already paid for stays readable. - Interrupted work is not refunded. A run that fails partway has consumed credits for the steps it completed. The events it emitted before failing stay readable.
- A follow-up pays for whatever it repeats. Runs are not resumable and
carry no history (One run, one exchange), so a
second run that redoes the first one's searches, transcription, or document
parsing is billed for that work again. Two ways to keep it down: supply the
inputs Mangrove needs up front, and quote the finding you want built on
rather than leaving Mangrove to rediscover it. Answering an
awaiting_inputrun is this same case — the new run repeats every step that led up to the question. - Organisation members. Accounts covered by an organisation subscription are metered for the organisation instead of drawing on personal API credits.
Example scenarios
Worked examples for specific ways people use the agents API.
Case 1: Sharing an image and getting Screen Time analysis
Someone exports their phone's Screen Time summary as a screenshot and wants more than the numbers read back to them — they want to know what to change. Uploading the image directly sends it to the vision model itself rather than running OCR on it, so bars, charts, and layout are read as visual content instead of as illegible text.
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "question=Summarize my screen time this week and tell me what to cut back on" \
-F "file=@screen-time.png"{ "agentId": "9b6e2f31-4a7c-4e9d-8b21-5f0a3c8d6e12", "status": "queued" }Looking at the image is a forced step, not something the model decides to
do, so it runs as step 1 before the model ever sees the question. The
uploaded file is stored briefly and handed to the run as a temporary link,
which is what vision receives:
event: step_start
data: {"type":"step_start","step":1,"description":"Executing tool"}
event: tool_call
data: {"type":"tool_call","toolName":"vision","input":{"imageUrl":"https://s3.fr-par.scw.cloud/…/screen-time.png?X-Amz-Signature=…","question":"Summarize my screen time this week and tell me what to cut back on"}}
event: tool_result
data: {"type":"tool_result","toolName":"vision","result":"You spent 4h 12m on your phone today, up from a 3h 34m daily average this week. Social Networking is your biggest category at 1h 48m — mostly Instagram (58m) and TikTok (42m) — followed by Safari (31m) and Messages (24m). You picked up your phone 87 times."}
event: step_complete
data: {"type":"step_complete","step":1}
event: step_start
data: {"type":"step_start","step":2,"description":"LLM agentic loop"}
event: text
data: {"type":"text","text":"You spent 4h 12m on your phone today, against a 3h 34m daily average this week. Social Networking dominates at 1h 48m — Instagram (58m) and TikTok (42m). The change with the most impact: mute non-essential notifications for both and set a 45-minute daily limit on each."}
event: step_complete
data: {"type":"step_complete","step":2}
event: step_start
data: {"type":"step_start","step":3,"description":"Confirming the answer is complete before stopping"}
event: text
data: {"type":"text","text":"You spent 4h 12m on your phone today, against a 3h 34m daily average this week. …"}
event: step_complete
data: {"type":"step_complete","step":3}
event: done
data: {"type":"done","finalText":"You spent 4h 12m on your phone today, against a 3h 34m daily average this week. Social Networking dominates at 1h 48m — Instagram (58m) and TikTok (42m). The change with the most impact: mute non-essential notifications for both and set a 45-minute daily limit on each."}Step 3 is the final check, and its text is the same answer restated in
full — abbreviated above so the transcript stays readable. Use
done.finalText.
Same path as a documentUrl image
Passing documentUrl pointing at a hosted image takes the identical
forced-vision-step path — only the link's origin differs: the temporary
one from an upload here, your own host there. Vision is forced rather
than left for the model to call itself, unlike document_parse below,
because the run already knows the image is there. Making the model
restate the link as tool-call output would cost a round trip and risk a
mistyped character, for nothing.
Case 2: Summarizing a document by URL
Someone has a link to a report and wants the key points and numbers pulled
out, not the whole document read back to them. document_parse fetches the
URL directly from GreenPT's servers and converts it to markdown — the URL
must be publicly reachable; a link behind auth or on a private network fails
the fetch (see the callout below).
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"question": "Summarize the key findings and pull out any concrete numbers",
"documentUrl": "https://files.example.com/whitepapers/eu-grid-carbon-intensity-2026.pdf"
}'{ "agentId": "5c8a1f42-6b3d-4e17-9a2c-8f1d3b7e40a6", "status": "queued" }Unlike the image case, document_parse is left for the model to call
itself rather than forced — a document URL is short regardless of the
file's size, so there's no cost in letting the model generate it as a
tool-call argument. Streaming (or polling) the run shows document_parse
running as a step the model chose to take, with text following once the
model has the markdown to answer from:
event: step_start
data: {"type":"step_start","step":1,"description":"LLM agentic loop"}
event: tool_call
data: {"type":"tool_call","toolName":"document_parse","input":"{\"url\":\"https://files.example.com/whitepapers/eu-grid-carbon-intensity-2026.pdf\"}"}
event: tool_result
data: {"type":"tool_result","toolName":"document_parse","result":"# EU Grid Carbon Intensity 2026\n\n## Key findings\n\nAverage grid carbon intensity across the EU fell to 158 gCO2e/kWh in 2026, down from 189 gCO2e/kWh in 2025..."}
event: text
data: {"type":"text","text":"The report finds EU grid carbon intensity averaged 158 gCO2e/kWh in 2026, down 16% from 189 gCO2e/kWh in 2025. Solar and wind combined reached 41% of generation, up from 34%. The steepest drop was in Q2, when intensity briefly fell below 90 gCO2e/kWh during a mid-day solar peak across Germany, France, and Spain."}
event: step_complete
data: {"type":"step_complete","step":1}
event: step_start
data: {"type":"step_start","step":2,"description":"Confirming the answer is complete before stopping"}
event: text
data: {"type":"text","text":"The report finds EU grid carbon intensity averaged 158 gCO2e/kWh in 2026, …"}
event: step_complete
data: {"type":"step_complete","step":2}
event: done
data: {"type":"done","finalText":"The report finds EU grid carbon intensity averaged 158 gCO2e/kWh in 2026, down 16% from 189 gCO2e/kWh in 2025. Solar and wind combined reached 41% of generation, up from 34%. The steepest drop was in Q2, when intensity briefly fell below 90 gCO2e/kWh during a mid-day solar peak across Germany, France, and Spain."}A failed fetch surfaces as a tool_result, not a run failure
If the URL can't be fetched (private, expired, wrong host),
document_parse returns an error string as its tool_result rather than
failing the run — the model sees Error: Failed to fetch document from URL: 404 Not Found and can act on it, e.g. asking for a working URL via
input_required instead of returning a confident-sounding non-answer.
Case 3: Summarizing a meeting
Someone has a recording of a meeting and wants a summary they can act on —
decisions, owners, and deadlines, not the transcript read back to them.
Passing audioUrl transcribes the recording before the model sees the
question, so the first thing the model does is answer with the whole meeting
already in context — it never has to decide to transcribe anything.
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"question": "What were the decisions and action items?",
"audioUrl": "https://example.com/standup.mp3"
}'{ "agentId": "e274b8a1-3f56-4c9e-9d02-6a1f8b53c7de", "status": "queued" }A recording you hold locally is uploaded the same way any other file is, and takes the same path — no hosting needed:
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "question=What were the decisions and action items?" \
-F "file=@standup.mp3"Transcription is a forced step, not something the model decides to do, so it
runs as step 1 before the model ever sees the question — the stream shows
audio_transcribe first, then the model's answer as step 2:
event: step_start
data: {"type":"step_start","step":1,"description":"Executing tool"}
event: tool_call
data: {"type":"tool_call","toolName":"audio_transcribe","input":{"url":"https://example.com/standup.mp3"}}
event: tool_result
data: {"type":"tool_result","toolName":"audio_transcribe","result":"Duration: 14:52\nDetected language: en\n\n[0:00] Speaker 0: Alright, let's get into it — where are we on the migration?\n[0:08] Speaker 1: Database side is done, we cut over Tuesday with no incidents. Still need to update the runbook.\n[0:22] Speaker 0: Can you own the runbook update by Friday?\n[0:25] Speaker 1: Yeah, Friday works.\n[0:31] Speaker 2: On the frontend side we're blocked on the design review — I need sign-off before I can start the rollout.\n[0:41] Speaker 0: I'll get you sign-off by tomorrow morning. Let's target rollout for next Wednesday."}
event: step_complete
data: {"type":"step_complete","step":1}
event: step_start
data: {"type":"step_start","step":2,"description":"LLM agentic loop"}
event: text
data: {"type":"text","text":"Decisions: target the frontend rollout for next Wednesday, pending design sign-off. Action items: Speaker 1 to update the migration runbook by Friday; Speaker 0 to deliver design sign-off to Speaker 2 by tomorrow morning. The database migration is complete with no incidents."}
event: step_complete
data: {"type":"step_complete","step":2}
event: step_start
data: {"type":"step_start","step":3,"description":"Confirming the answer is complete before stopping"}
event: text
data: {"type":"text","text":"Decisions: target the frontend rollout for next Wednesday, pending design sign-off. …"}
event: step_complete
data: {"type":"step_complete","step":3}
event: done
data: {"type":"done","finalText":"Decisions: target the frontend rollout for next Wednesday, pending design sign-off. Action items: Speaker 1 to update the migration runbook by Friday; Speaker 0 to deliver design sign-off to Speaker 2 by tomorrow morning. The database migration is complete with no incidents."}Speaker labels are numbers, not names
Diarization identifies distinct speakers but not who they are — the
transcript labels them Speaker 0, Speaker 1, and so on. The model maps
a label to a name only when the transcript or your question makes that
mapping clear (someone addressed by name, or named in the question
itself); otherwise the summary refers to them by number, as above.
A question that clearly needs a recording, asked without audioUrl and
without enough question text to be a pasted transcript, may end with an
awaiting_input status asking for one instead of guessing — see Handling
awaiting_input for what that looks like.
Case 4: Getting the answer back as a file
Someone wants the output as something they can pass on — a CSV to open in a spreadsheet, a markdown summary to drop into a document. Ask for it in the question, and Mangrove writes the file and answers with a link to it.
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"question": "Compare the 2025 and 2026 grid intensity figures and save the comparison as a CSV I can download",
"documentUrl": "https://files.example.com/whitepapers/eu-grid-carbon-intensity-2026.pdf"
}'The model decides to call save_file itself, the same way it decides to
search the web. The link arrives as the tool's result, and Mangrove repeats
it in its answer:
event: tool_call
data: {"type":"tool_call","toolName":"save_file","input":"{\"filename\":\"eu-grid-intensity-2025-2026.csv\",\"content\":\"year,gco2e_per_kwh\\n2025,189\\n2026,158\\n\"}"}
event: tool_result
data: {"type":"tool_result","toolName":"save_file","result":"Saved \"eu-grid-intensity-2025-2026.csv\". Download link (valid until 2026-07-28T10:00:00.000Z): https://files.example.com/…?X-Amz-Signature=…"}The link carries its own signed authorization, so it needs no API key and can go straight to a browser or a colleague. That cuts both ways: anyone holding it can download the file until it expires, roughly a day after the run.
Saved files are text — markdown, CSV, JSON, plain text — up to 1 MB. Where
file export is not enabled, the call comes back as Error: File export is not enabled and Mangrove falls back to putting the content in its answer.
API Reference
Authentication
All requests require an API key passed as a Bearer token in the
Authorization header:
Authorization: Bearer YOUR_API_KEYCreate a run
POST https://api.greenpt.ai/v1/agents/runsTwo ways to start a run:
- JSON — a
question, plusdocumentUrloraudioUrlwhen the question is about something at a URL. multipart/form-data— aquestionplus afile. An image goes straight to the vision model, a recording is transcribed, and anything else is converted to text. The run answers from the file's content either way, so nodocumentUrloraudioUrlis needed for a local file.
JSON body
| Field | Type | Required | Description |
|---|---|---|---|
question | string | Yes | What you want Mangrove to do. |
documentUrl | string (URL) | No | A document or image URL to analyze. |
audioUrl | string (URL) | No | A meeting recording URL to transcribe. |
multipart/form-data fields
| Field | Type | Required | Description |
|---|---|---|---|
question | string | Yes | What you want Mangrove to do. |
file | binary | Yes | An image, a recording, or a document. The extension decides which — see below. |
The extension decides how a file is read, and each kind carries its own size
limit. A file over its limit is rejected with 413 before any work starts.
| Kind | Extensions | Limit |
|---|---|---|
| Image | .png, .jpg, .jpeg, .webp, .gif | 10 MB |
| Recording | .mp3, .m4a, .wav, .ogg, .opus, .flac, .aac, .webm, .mp4 | 250 MB |
| Document | PDF, DOCX, and the other formats the Documents API accepts | 50 MB |
Response
{ "agentId": "3fa2b1e4-8c5d-4a2e-9f1b-7d6e2c9a91cd", "status": "queued" }Example: ask a question
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"question": "What does GreenPT offer?"
}'const response = await fetch('https://api.greenpt.ai/v1/agents/runs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer YOUR_API_KEY',
},
body: JSON.stringify({ question: 'What does GreenPT offer?' }),
});
const { agentId } = await response.json();
console.log(agentId);import requests
response = requests.post(
"https://api.greenpt.ai/v1/agents/runs",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"question": "What does GreenPT offer?"},
)
agent_id = response.json()["agentId"]
print(agent_id)Example: analyze a document or image by URL
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"question": "Summarize the key findings",
"documentUrl": "https://example.com/report.pdf"
}'const response = await fetch('https://api.greenpt.ai/v1/agents/runs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer YOUR_API_KEY',
},
body: JSON.stringify({
question: 'Summarize the key findings',
documentUrl: 'https://example.com/report.pdf',
}),
});
const { agentId } = await response.json();
console.log(agentId);import requests
response = requests.post(
"https://api.greenpt.ai/v1/agents/runs",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"question": "Summarize the key findings",
"documentUrl": "https://example.com/report.pdf",
},
)
agent_id = response.json()["agentId"]
print(agent_id)Example: analyze a meeting recording
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"question": "What were the decisions and action items?",
"audioUrl": "https://example.com/standup.mp3"
}'const response = await fetch('https://api.greenpt.ai/v1/agents/runs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer YOUR_API_KEY',
},
body: JSON.stringify({
question: 'What were the decisions and action items?',
audioUrl: 'https://example.com/standup.mp3',
}),
});
const { agentId } = await response.json();
console.log(agentId);import requests
response = requests.post(
"https://api.greenpt.ai/v1/agents/runs",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"question": "What were the decisions and action items?",
"audioUrl": "https://example.com/standup.mp3",
},
)
agent_id = response.json()["agentId"]
print(agent_id)Example: upload a file directly
curl -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "question=What does this chart show?" \
-F "file=@chart.png"import fs from 'node:fs';
const form = new FormData();
form.append('question', 'What does this chart show?');
form.append('file', new Blob([fs.readFileSync('chart.png')]), 'chart.png');
const response = await fetch('https://api.greenpt.ai/v1/agents/runs', {
method: 'POST',
headers: { Authorization: 'Bearer YOUR_API_KEY' },
body: form,
});
const { agentId } = await response.json();
console.log(agentId);import requests
url = "https://api.greenpt.ai/v1/agents/runs"
headers = {"Authorization": "Bearer YOUR_API_KEY"}
data = {"question": "What does this chart show?"}
with open("chart.png", "rb") as f:
files = {"file": ("chart.png", f)}
response = requests.post(url, headers=headers, data=data, files=files)
agent_id = response.json()["agentId"]
print(agent_id)Get run status
GET https://api.greenpt.ai/v1/agents/runs/{agentId}| Field | Type | Description |
|---|---|---|
agentId | string | The run's ID. |
status | string | queued, running, succeeded, failed, or awaiting_input. |
result | string | Present on succeeded (Mangrove's answer) and awaiting_input (what it needs from you). |
error | string | Present on failed. |
createdAt / updatedAt | string (ISO 8601) | Timestamps for the run. |
expiresAt | string (ISO 8601) | When the run and its events are deleted — 24 hours after creation. |
succeeded, failed, and awaiting_input are all terminal — a run in any of
them is finished for good, and stays readable until expiresAt without being
resumable (One run, one exchange). For
awaiting_input specifically, start a new run that includes the field named
in result, or in the streamed input_required event.
curl https://api.greenpt.ai/v1/agents/runs/3fa2b1e4-8c5d-4a2e-9f1b-7d6e2c9a91cd \
-H "Authorization: Bearer YOUR_API_KEY"{
"agentId": "3fa2b1e4-8c5d-4a2e-9f1b-7d6e2c9a91cd",
"status": "succeeded",
"result": "GreenPT offers sustainable AI inference hosted on EU infrastructure, including chat completions, document processing, web search, and autonomous agents.",
"createdAt": "2026-07-27T10:00:00.000Z",
"updatedAt": "2026-07-27T10:00:04.000Z",
"expiresAt": "2026-07-28T10:00:00.000Z"
}A run ID that does not exist, or belongs to a different API key, returns a 404 with the same body either way — existence of another caller's run is never revealed:
{ "error": "Not found" }Stream run events
GET https://api.greenpt.ai/v1/agents/runs/{agentId}/streamStreams the run as Server-Sent Events instead of polling. Reconnecting with
a Last-Event-ID header replays every event after that ID, then tails new
ones live — useful for recovering from a dropped connection without missing
anything.
curl -N https://api.greenpt.ai/v1/agents/runs/3fa2b1e4-8c5d-4a2e-9f1b-7d6e2c9a91cd/stream \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Accept: text/event-stream"| Event | Payload | Meaning |
|---|---|---|
step_start | { step, description } | A new step began. |
tool_call | { toolName, input } | Mangrove is calling a tool. input is an object on a forced step and a JSON string when the model generated it. |
tool_result | { toolName, result } | The tool's result, fed back to Mangrove. |
text | { text } | A whole message Mangrove produced, not a token-by-token delta. A run emits more than one. |
step_complete | { step } | The step finished. |
input_required | { message, expected } | Terminal — Mangrove needs input only you can provide. |
done | { finalText } | Terminal — the run succeeded; finalText is the answer. |
error | { message } | Terminal — the run failed. |
toolName is usually one of the tools GET /v1/agents lists, but not
always: Mangrove also plans its work with write_todos, which emits the
same tool_call and tool_result pair. Treat an unrecognized toolName as
something to display rather than an error.
Example transcript for a research run that searches the web before answering:
event: step_start
data: {"type":"step_start","step":1,"description":"LLM agentic loop"}
event: tool_call
data: {"type":"tool_call","toolName":"web_search","input":"{\"query\":\"sustainable data center cooling\"}"}
event: tool_result
data: {"type":"tool_result","toolName":"web_search","result":"[{\"title\":\"Liquid cooling adoption accelerates\",\"url\":\"https://example.com/article\",\"snippet\":\"...\"}]"}
event: text
data: {"type":"text","text":"Data centers are increasingly adopting liquid cooling to cut energy use..."}
event: step_complete
data: {"type":"step_complete","step":1}
event: step_start
data: {"type":"step_start","step":2,"description":"Confirming the answer is complete before stopping"}
event: text
data: {"type":"text","text":"Data centers are increasingly adopting liquid cooling to cut energy use..."}
event: step_complete
data: {"type":"step_complete","step":2}
event: done
data: {"type":"done","finalText":"Data centers are increasingly adopting liquid cooling to cut energy use..."}Reading a stream without duplicating the answer
The last step of every run is the final check described in Inside a
run, and it asks the model to restate its answer in full. So
a run emits its answer at least twice: once when the model first produces it,
once more from the check. Concatenating every text event gives you the
answer two or three times over.
Two ways to read a stream correctly:
- Use
done.finalText. It is the last message the model produced, which is the answer after any correction the check made. Treattextas progress to display while the run is working, not as the thing to keep. - Reset on
step_start. If you are rendering text as it arrives, clear what you have buffered at each new step. Each step's text then stands on its own, and the final check replaces the draft rather than appending to it.
Polling sidesteps this entirely: result on a succeeded run is the same
string as finalText.
Handling awaiting_input
A run that lacks something it needs ends with an input_required event
instead of tool_call/text/done — for example, a question that asks to
summarize a meeting recording without an audioUrl and without enough
question text to be a pasted transcript, where Mangrove asks for the
recording rather than guessing:
event: input_required
data: {"type":"input_required","message":"I can analyze a meeting recording, but I need the audio first. Create a new run with an audioUrl pointing to the recording, or include the transcript text in the question.","expected":[{"name":"audioUrl","description":"URL of the meeting recording to transcribe"}]}There is no resume: start a new run carrying the field named in expected.
Discovery
GET https://api.greenpt.ai/v1/agentsTwo response modes depending on the request:
- No
Authorizationheader, or anAccept: text/markdownheader — a markdown document describing the available agents, readable without an API key. - An authenticated request not asking for markdown — a JSON listing of every agent and the input fields it accepts.
curl https://api.greenpt.ai/v1/agents \
-H "Authorization: Bearer YOUR_API_KEY"{
"agents": [
{
"id": "default",
"displayName": "Agent",
"description": "Works a task to completion using web search, page scraping, document and image analysis, and meeting-recording transcription.",
"tools": ["web_search", "web_crawl", "document_parse", "vision", "audio_transcribe", "save_data", "save_file"],
"input": {
"type": "object",
"properties": {
"question": { "type": "string", "description": "What you want the agent to do" },
"documentUrl": { "type": "string", "description": "URL of a document to analyze (PDF, DOCX, etc.)" },
"audioUrl": { "type": "string", "description": "URL of a meeting recording to transcribe" }
},
"required": ["question"]
}
}
]
}End-to-end example
Create a run, then stream it until done:
AGENT_ID=$(curl -s -X POST https://api.greenpt.ai/v1/agents/runs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"question": "What does GreenPT offer?"}' | jq -r .agentId)
curl -N https://api.greenpt.ai/v1/agents/runs/$AGENT_ID/stream \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Accept: text/event-stream"const BASE = 'https://api.greenpt.ai';
const KEY = process.env.GREENPT_API_KEY;
const created = await fetch(`${BASE}/v1/agents/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}` },
body: JSON.stringify({ question: 'What does GreenPT offer?' }),
});
const { agentId } = await created.json();
const stream = await fetch(`${BASE}/v1/agents/runs/${agentId}/stream`, {
headers: { Accept: 'text/event-stream', Authorization: `Bearer ${KEY}` },
});
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of stream.body) {
buffer += decoder.decode(chunk, { stream: true });
let i;
while ((i = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, i);
buffer = buffer.slice(i + 2);
const data = raw
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim())
.join('\n');
if (!data) continue;
const event = JSON.parse(data);
// `text` is progress and repeats across steps, so it goes to stderr;
// `finalText` is the answer. See "Reading a stream without duplicating
// the answer" above.
if (event.type === 'text') process.stderr.write(event.text);
if (event.type === 'done') console.log(event.finalText);
if (event.type === 'input_required') console.log('Needs:', event.expected);
if (event.type === 'error') console.error('Failed:', event.message);
}
}import json
import os
import sys
import requests
BASE = "https://api.greenpt.ai"
KEY = os.environ["GREENPT_API_KEY"]
headers = {"Authorization": f"Bearer {KEY}"}
created = requests.post(
f"{BASE}/v1/agents/runs",
headers={**headers, "Content-Type": "application/json"},
json={"question": "What does GreenPT offer?"},
)
agent_id = created.json()["agentId"]
with requests.get(
f"{BASE}/v1/agents/runs/{agent_id}/stream",
headers={**headers, "Accept": "text/event-stream"},
stream=True,
) as response:
for line in response.iter_lines(decode_unicode=True):
if not line or not line.startswith("data:"):
continue
event = json.loads(line[len("data:"):].strip())
# `text` is progress and repeats across steps, so it goes to stderr;
# `finalText` is the answer. See "Reading a stream without duplicating
# the answer" above.
if event["type"] == "text":
print(event["text"], end="", file=sys.stderr, flush=True)
elif event["type"] == "done":
print(event["finalText"])
elif event["type"] == "input_required":
print("Needs:", event["expected"])
elif event["type"] == "error":
print("Failed:", event["message"])Polling instead of streaming works the same way: call
GET /v1/agents/runs/{agentId} on an interval until status is
succeeded, failed, or awaiting_input.
Errors
| Status | When | Body |
|---|---|---|
| 400 | Missing or invalid fields | Plain-text validation message |
| 401 | Missing or invalid API key | Unauthorized |
| 402 | The account has no API credits left. Run creation only — polling and streaming existing runs keep working | {"error": "No remaining credits (EUR)"} |
| 404 | Unknown run ID, or a run belonging to another API key | {"error": "Not found"} |
| 413 | An uploaded file is over the limit for its kind — 10 MB image, 250 MB recording, 50 MB document | Plain-text message naming the file and the limit |
| 415 | The uploaded file's extension is not one the document converter accepts | Plain-text message naming the extension and listing the accepted ones |
| 429 | Rate limit exceeded — 600 requests per 15-minute window per account, shared across every GreenPT API endpoint | Plain-text Too many requests, please try again later., with RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and Retry-After headers |
| 502 | The vision model or document conversion failed while reading an uploaded file | Plain-text message describing the failure |
| 503 | Something the run needs is switched off or unreachable for the environment | Plain-text, one of Agent service is not enabled, Temporary file storage is not enabled, Temporary file storage is unavailable, or Document conversion service is not enabled |
A 503 carries four distinct causes, so read the body rather than the status:
only the first means Mangrove is switched off. The others mean uploads are
unavailable while the rest of the API works normally.
Limits
| What | Limit |
|---|---|
| Tool-calling rounds in a run | 20 |
| Output per completion Mangrove makes | 4096 tokens |
| How long a run stays readable | 24 hours after creation |
| Uploaded image | 10 MB |
| Uploaded recording | 250 MB |
| Uploaded document | 50 MB |
| A file Mangrove saves | 1 MB of text |
| A value Mangrove remembers | 10,000 characters |
| Requests | 600 per 15-minute window per account, across every GreenPT endpoint |
A run that reaches the 20-round cap stops calling tools and answers with what
it has by then. It reports succeeded like any other run, and nothing in the
stream marks the difference — a task that needed more work than that comes
back with a thinner answer rather than an error. Supplying the inputs up
front, or splitting the task into narrower questions, keeps a run well inside
the cap.
Use cases
- Research a topic: ask a question with no extra input — Mangrove searches the web and cites what it finds.
- Summarize a document or chart: pass
documentUrl(or upload the file directly) alongside a question about it. - Understand a screenshot or photo: upload an image, or point
documentUrlat one — Mangrove looks at it directly rather than running OCR, so it can describe layout, charts, and visual content OCR would miss. - Summarize a meeting: pass
audioUrl(or upload the recording) for a speaker-attributed summary, decisions, and action items. - Take the answer away as a file: ask for the output as a CSV, a markdown summary, or a JSON blob, and Mangrove answers with a download link — see Case 4.
- Compare against a previous run: ask Mangrove to remember a value under a name, then ask for it again on a later run to compare against what it saved before.
- Follow up on an answer: start another run quoting the part of the previous answer you want built on — see Asking a follow-up.