GreenPT Docs

Mangrove

Run Mangrove to process images, research the web, analyse documents, or transcribe your meetings.

POST

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

  1. First, get a free API key with 5 Euros on us here.
  2. 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.

Loading API key…
Loading Mangrove…

Concepts

The general flow for using GreenPT's Mangrove is:

  1. Send your question to Mangrove — you get back an agent ID.
  2. Hold on to that ID. It is how you follow the run's progress and read its answer.
  3. Mangrove works through the task and answers. If it needs something only you can provide, it says so and stops.
  4. Want to take it further? Send another question — that starts a new run.
  5. Repeat :-)

Inside a run

Every run moves through the same phases, and the event stream shows them as numbered steps:

  1. 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_parse itself, for the reason in Case 2.
  2. The agentic loop, where the model calls whichever tools it decides it needs, over at most 20 rounds.
  3. 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 vision step is a completion of its own, billed at the rate for pixtral-12b-2409.
  • Transcription — the audio_transcribe step is billed per second of audio at the speech-to-text rates.
  • Tool callsweb_search, web_crawl, document_parse, save_data, and save_file carry 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-Remaining header on the 202 response reflects the balance before the run, not the run's cost.
  • A spent balance rejects new runs, not reads. POST /v1/agents/runs returns 402 Payment Required when 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_input run 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_KEY

Create a run

POST https://api.greenpt.ai/v1/agents/runs

Two ways to start a run:

  • JSON — a question, plus documentUrl or audioUrl when the question is about something at a URL.
  • multipart/form-data — a question plus a file. 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 no documentUrl or audioUrl is needed for a local file.

JSON body

FieldTypeRequiredDescription
questionstringYesWhat you want Mangrove to do.
documentUrlstring (URL)NoA document or image URL to analyze.
audioUrlstring (URL)NoA meeting recording URL to transcribe.

multipart/form-data fields

FieldTypeRequiredDescription
questionstringYesWhat you want Mangrove to do.
filebinaryYesAn 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.

KindExtensionsLimit
Image.png, .jpg, .jpeg, .webp, .gif10 MB
Recording.mp3, .m4a, .wav, .ogg, .opus, .flac, .aac, .webm, .mp4250 MB
DocumentPDF, DOCX, and the other formats the Documents API accepts50 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}
FieldTypeDescription
agentIdstringThe run's ID.
statusstringqueued, running, succeeded, failed, or awaiting_input.
resultstringPresent on succeeded (Mangrove's answer) and awaiting_input (what it needs from you).
errorstringPresent on failed.
createdAt / updatedAtstring (ISO 8601)Timestamps for the run.
expiresAtstring (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}/stream

Streams 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"
EventPayloadMeaning
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. Treat text as 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/agents

Two response modes depending on the request:

  • No Authorization header, or an Accept: text/markdown header — 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

StatusWhenBody
400Missing or invalid fieldsPlain-text validation message
401Missing or invalid API keyUnauthorized
402The account has no API credits left. Run creation only — polling and streaming existing runs keep working{"error": "No remaining credits (EUR)"}
404Unknown run ID, or a run belonging to another API key{"error": "Not found"}
413An uploaded file is over the limit for its kind — 10 MB image, 250 MB recording, 50 MB documentPlain-text message naming the file and the limit
415The uploaded file's extension is not one the document converter acceptsPlain-text message naming the extension and listing the accepted ones
429Rate limit exceeded — 600 requests per 15-minute window per account, shared across every GreenPT API endpointPlain-text Too many requests, please try again later., with RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and Retry-After headers
502The vision model or document conversion failed while reading an uploaded filePlain-text message describing the failure
503Something the run needs is switched off or unreachable for the environmentPlain-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

WhatLimit
Tool-calling rounds in a run20
Output per completion Mangrove makes4096 tokens
How long a run stays readable24 hours after creation
Uploaded image10 MB
Uploaded recording250 MB
Uploaded document50 MB
A file Mangrove saves1 MB of text
A value Mangrove remembers10,000 characters
Requests600 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 documentUrl at 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.

On this page