What We’re Building
The reference implementation is a small Python package with one job per module:
A single question flows through it like this:
- Ask Venice for the current function-calling model, unless you pinned one.
- Connect to the Apify MCP server and list its tools.
- Rewrite those MCP tools as OpenAI-compatible function definitions.
- Send the question with the tool list attached.
- If the model returns
tool_calls, run them against Apify and append the results astoolmessages. - Repeat until the model answers with text instead of a tool call.
This agent can spend Apify compute on your account. Start without
APIFY_TOKEN if you only want search and documentation tools, and leave --yes off until you actually intend to run Actors.Setting Up the Project
The reference project uses Python 3.12+ and uv. Create a new project:httpx2, the 2.x line of httpx, which both openai and mcp already depend on. Installing it directly avoids ending up with two HTTP clients in the same environment.
Then create a .env file:
VENICE_API_KEY comes from Venice API settings. APIFY_TOKEN comes from Apify Console and is optional — we’ll cover what you get without it in a moment.
Loading Configuration
Settings come first because every other module takes them as an argument. We’ll usepydantic-settings so environment variables, .env, and CLI flags all land in one validated object.
In src/venice_terminal_agent/config.py, a Settings(BaseSettings) class carries the fields that matter:
venice_model is None rather than a model ID, which we’ll come back to in the next section. And max_rounds with max_tool_result_chars are the limits that stop an agent running away: the first caps how many tool rounds one question can take, the second caps how much of a scraped page gets fed back into context.
The interesting function in this module is the URL builder:
tools query parameter that decides which tools it advertises. Without an APIFY_TOKEN we ask for the four anonymous tools that work unauthenticated — Actor search, Actor details, documentation search, and documentation fetch. That means someone can clone the project, add only a Venice key, and still get a working agent that can research Apify Actors. They just cannot run one.
Talking to Venice
Venice is OpenAI-compatible, so we can use the OpenAI SDK for chat completions and plainhttpx for the model-discovery call.
Create src/venice_terminal_agent/venice.py:
AsyncOpenAI gives us the streaming helper and typed tool_calls for free. The raw httpx client is there for Venice endpoints the OpenAI SDK does not know about, which in this project means /models/traits.
The chat timeout is much longer than the discovery timeout on purpose. A question that triggers a web crawl can legitimately take a couple of minutes.
Discovering a Model at Runtime
Venice model IDs rotate, and hardcoding one is the fastest way to ship an agent that breaks in a month.GET /models/traits maps stable trait names onto whatever model currently fills that role, so we ask for function_calling_default instead of naming a model:
--model flag wins, then VENICE_MODEL from the environment, then the trait lookup. So the default path needs no configuration at all, but you can still pin a model when you are comparing behaviour between two of them.
Streaming Completions
Now add the completion call:stream() context manager handles both: content.delta events drive the terminal output, and get_final_completion() hands back a complete message with tool_calls already stitched together.
The request itself is built by a separate function so it stays easy to test:
extra_body is how the OpenAI SDK passes through fields it does not model, which is where venice_parameters goes. Setting include_venice_system_prompt to false keeps Venice’s default assistant prompt out of the conversation, so our own system prompt is the only instruction the model gets. For an agent with strict tool rules, that is what you want.
Only attach tools and tool_choice when there is at least one tool. Sending an empty tools array is a needless way to confuse a model.
The module also has a format_http_error() helper that turns an APIStatusError or an httpx2.HTTPStatusError into a one-line string with the status code and response body. Agents fail at the API boundary more often than anywhere else, and a readable message there saves a lot of guessing.
Converting MCP Tools into Venice Tools
MCP tools and OpenAI-style function tools describe the same thing in different shapes. Both have a name, a description, and a JSON Schema for arguments. The translation is mostly mechanical, with one catch: Apify tool names include characters that function names do not allow. An Actor tool might be calledapify/rag-web-browser, and that slash is not valid.
So we sanitize the names on the way out and keep a map so we can restore them on the way back in.
In src/venice_terminal_agent/tools.py, a ToolCatalog does the translation and holds the map:
sanitize_tool_name() replaces illegal characters with hyphens, prefixes names that start with a digit, and truncates to 64 characters. unique_name() then appends a numeric suffix if that truncation made two Actors collide — which saves you a genuinely confusing bug where the model calls one Actor and a different one runs. tool_input_schema() copes with MCP servers handing back a dict, a Pydantic model, or nothing at all.
Formatting Results Back into Context
Tool results go straight into the conversation, so they need to be a string, and they need a size limit. Scraping a documentation site can easily return more text than the context window holds.format_tool_result() prefers structured_content when the server provides it, and otherwise flattens the content blocks into text, coping with blocks that are not TextContent. It ends with the two lines that matter:
{"error": "..."} rather than raised. A failed tool call is information the model can act on — it can pick a different Actor or fix its arguments — and it can only do that if the failure reaches it as a normal tool result.
Marking the Tools That Cost Money
Apify tools split cleanly into two groups: ones that read metadata and documentation, and ones that start compute. We want confirmation for the second group, so we allowlist the first:Connecting to Apify over MCP
Apify offers two ways in. The hosted server athttps://mcp.apify.com speaks Streamable HTTP, and @apify/actors-mcp-server runs locally over stdio via npx. We’ll support both, since they suit different situations: hosted needs no Node.js, and stdio keeps the connection on your own machine.
In src/venice_terminal_agent/apify_mcp.py, an ApifyMcp class wraps the connected session. Its call_tool() is where the sanitized name gets translated back — Venice sends apify-rag-web-browser, Apify receives apify/rag-web-browser:
client.list_tools(), since a token with access to many Actors produces a paginated list.
Owning the Transport
An MCP connection is a long-lived async resource, and so is the HTTP client underneath it. AnApifyMcpSession async context manager holds both in an AsyncExitStack, picks a transport based on settings, and loads the catalog. The detail worth copying is the cleanup:
except BaseException matters more than it looks. If listing tools fails after the transport is up, without it you leak a subprocess or an open socket every time the agent fails to start.
Here are the two transports:
APIFY_TOKEN in its environment, not your whole shell environment — including your Venice key.
Executing a Tool Call
The last piece of this module,execute_venice_tool_call(), turns a Venice tool call into a string result. It wraps both classes of failure — unparseable arguments and a failed Apify call — as {"error": "..."} rather than raising:
{"error": "invalid arguments: ..."} gets you a corrected call on the next round, whereas raising kills the session and loses the conversation.
Running the Tool Loop
Now for the agent itself, insrc/venice_terminal_agent/agent.py. Start with the system prompt:
search-actors and fetch-actor-details before calling an unfamiliar Actor” exists because a model that guesses at an Actor’s input schema wastes a paid run. The line about declined tools exists because otherwise the model treats a refusal as a transient error and immediately tries again.
The Agent class takes the two clients, a model, a round limit, and three callbacks:
on_tool reports a tool call, on_text receives streamed tokens, and approve_tool answers the confirmation question. Swap them out and the same agent works behind a web app or a chat bot.
Here is the loop:
start index and the del in the exception handler are worth a closer look. If a question fails halfway through — network error, Ctrl+C, round limit — the conversation is left with an assistant turn requesting tools that never produced results. Venice will reject the next request, because a tool_calls turn must be followed by matching tool messages. Rolling back to where the question started means a failed question leaves no trace and the REPL stays usable.
Echoing the Assistant Turn
This next function is small and easy to get wrong:message.model_dump(exclude_none=True), and it breaks tool calling. A tool-call turn has content: null, and dropping that key changes the shape of the message you send back. exclude_unset=True is the version you want: it keeps null values the model actually set, and omits fields it never sent.
It also preserves fields the OpenAI schema does not know about. Reasoning models return reasoning_content and reasoning_details, and those need to survive the round trip so the model keeps its own chain of thought across tool rounds.
Executing and Gating Calls
Models can request several tools in one turn, and there is no reason to run them one at a time. But we do want to ask for approval sequentially, since interleaved confirmation prompts would be unreadable. So we plan first, then execute concurrently:tool message. Every tool_call_id needs a reply, and skipping one leaves the conversation malformed. The reply just happens to explain that the user said no.
The approval check itself consults both names, since the model works with sanitized names and our allowlist uses MCP names:
Adding the CLI
The CLI insrc/venice_terminal_agent/cli.py is Typer plus a REPL, and it is the least interesting file in the project — but three details in it are worth copying.
The first is that the Typer options are typed as optional and default to None, so the settings loader can tell “not passed” from “passed a falsy value”:
None defaults are what make the handoff to load_settings() safe, since a flag you did not use never overrides the environment:
yes or None is the same idea applied to a boolean flag: --yes sets it, and omitting it passes None rather than False, so AUTO_APPROVE_TOOLS from the environment survives.
The second is startup order. Resolve the model, then open the MCP session, then build the agent — and close the Venice client in a finally, since the MCP session and the HTTP clients both need unwinding whether or not the question succeeded:
isatty() check is the part people forget. Run the agent from cron or CI and there is nobody to answer the prompt, so a naive implementation either hangs forever or silently approves. Here it declines, says why, and lets the model carry on with the read-only tools. default=False means a stray Enter does not start a paid run, and interrupting the prompt counts as a no.
The rest of the module is ordinary terminal work, so it is worth knowing what is there rather than reading it: a prompt_toolkit REPL loop, a _handle_command() lookup for the slash commands, a render.py of Rich helpers, and a _settings_error() that turns a missing VENICE_API_KEY into a readable message instead of a Pydantic traceback. Three of those carry a decision:
The slash commands are
/help, /clear, /quit, and two that earn their keep: /tools prints the loaded catalog, which usually explains why the agent picked an odd tool, and /reload picks up Actors you added to your Apify account mid-session.
Finally, wire the entry point up in pyproject.toml so uv run venice-agent works:
Running the Agent
Start an interactive session:APIFY_TOKEN did not load, and that is much better to notice now than after ten minutes of wondering why the agent refuses to run an Actor.
Restrict the tool catalog when you know what you need:
--tools is the cheapest way to narrow the choice.
Run the MCP server locally instead of using the hosted one:
@apify/actors-mcp-server through npx, and it needs an APIFY_TOKEN — there is no anonymous mode for the local server.
And when you genuinely want unattended Actor runs:
Testing the Pieces
None of the interesting logic here needs a network. AFakeVenice that pops from a scripted list of replies, plus a FakeApify that builds a real ToolCatalog from SimpleNamespace tools, is enough to drive a full tool round:
agent.messages in the same way. That a failed run rolls history back to just ["system"], whether it failed on a Venice error or by exhausting max_rounds. That a read-only tool still runs when the approver returns False. And that a declined paid tool leaves a tool message containing declined while apify.calls stays empty.
Run the suite with:
Privacy and Cost Notes
An agent that reaches two APIs is worth being precise about:
Venice’s zero data retention covers the model side. It does not cover Apify, and an Actor run writes results into your Apify account. If that matters for a particular task, run without
APIFY_TOKEN and stick to the anonymous discovery tools.
On cost, three habits go a long way:
- Leave
--yesoff during development. Watching which Actors the model wants to run is informative in itself. - Use
--toolsto narrow the catalog to Actors you have actually reviewed. - Keep
max_roundsmodest. Twelve rounds is plenty for research tasks, and a lower ceiling caps the damage when a model gets stuck in a loop.
Extending This Example
The loop is the foundation. Once it works, useful directions include:- Add a second MCP server. Nothing in
Agentis Apify-specific, so merging catalogs from several servers mostly means namespacing tool names. - Persist conversations to SQLite so you can resume a session or audit what an Actor returned.
- Add per-tool budgets that track Actor runs and stop at a ceiling, rather than confirming each one.
- Cache tool results by name and arguments, so repeated documentation lookups do not re-crawl.
- Pin a model with
--modeland compare tool-selection quality againstfunction_calling_default. - Swap the approver for a policy function that auto-approves specific Actors with specific arguments and prompts for everything else.