Skip to main content
A model on its own cannot tell you what is on the front page of Hacker News right now. To do that, it needs tools, and someone has to build and maintain those tools. Apify already did: it hosts thousands of Actors that scrape sites, crawl documentation, and pull structured data, and it exposes them over the Model Context Protocol. That combination is a good fit for Venice. Venice supplies OpenAI-compatible function calling with no data retention, Apify supplies the tools, and MCP is the wire format between them. You do not write a scraper per site — you connect once and let the model pick the Actor. In this tutorial, we’ll build a terminal agent in Python that does exactly that. By the end, you’ll have a CLI that discovers a Venice function-calling model at runtime, loads the Apify tool catalog over MCP, streams answers into your terminal, and asks before it spends money on an Actor run. Interested in the full code implementation? Check out the GitHub repo. Before we continue, you’ll need a Venice API key:

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:
  1. Ask Venice for the current function-calling model, unless you pinned one.
  2. Connect to the Apify MCP server and list its tools.
  3. Rewrite those MCP tools as OpenAI-compatible function definitions.
  4. Send the question with the tool list attached.
  5. If the model returns tool_calls, run them against Apify and append the results as tool messages.
  6. Repeat until the model answers with text instead of a tool call.
Steps 4 through 6 are the whole agent. Everything else exists to make those three steps safe and pleasant to use.
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:
Install the dependencies:
That is 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 use pydantic-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:
Two of these carry decisions rather than defaults. 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:
The hosted Apify MCP server takes a 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 plain httpx for the model-discovery call. Create src/venice_terminal_agent/venice.py:
Two clients for one API looks redundant, but they do different jobs. 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:
The precedence here matters: an explicit --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.
Not every text model supports function calling. Asking for the function_calling_default trait means you get one that does, without maintaining a list yourself. See Deprecations for how often the underlying IDs change.

Streaming Completions

Now add the completion call:
We stream so the user sees text appear as it is generated, but we still want the assembled message afterwards — tool calls arrive in fragments across many chunks, and reassembling them by hand is tedious. The SDK’s 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 called apify/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:
Three small helpers do the unglamorous work. 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:
The truncation notice is written for the model, not for you. Telling it that content was cut and suggesting filters, limits, or offsets is usually enough for it to make a narrower second call instead of assuming it saw everything. Errors get wrapped as {"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:
An allowlist rather than a blocklist is the important choice. Apify keeps adding tools and Actors, and anything the agent has not seen before defaults to asking first. Get this backwards and every new Actor is auto-approved.

Connecting to Apify over MCP

Apify offers two ways in. The hosted server at https://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:
Building the catalog needs a cursor loop over 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. An ApifyMcpSession 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:
That 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:
Note the read timeout of 300 seconds on the HTTP transport. Actor runs are slow, and the default 30-second timeout will cut off perfectly healthy crawls. Note also that the stdio subprocess gets only 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:
Malformed JSON arguments happen. When they do, handing the model {"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, in src/venice_terminal_agent/agent.py. Start with the system prompt:
Every rule there maps to a specific failure we want to avoid. “Prefer 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:
Those callbacks are what keep the agent independent of the terminal. 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:
That is the entire agent: call the model, and if it asked for tools, run them and call again. The 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:
The obvious implementation is 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:
Declined tools still get a 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 in src/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”:
Those None defaults are what make the handoff to load_settings() safe, since a flag you did not use never overrides the environment:
The 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:
The third is the approver, which is the one piece of the agent that exists purely to protect your Apify bill:
The 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:
Or ask one question and exit:
You’ll see the banner, then the tool calls as they happen:
Read the Apify line on that banner before anything else. If it says “anonymous Apify tools only”, your 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:
A smaller catalog is not just about cost. Models generally pick better when there are fewer, more relevant tools to choose between, and --tools is the cheapest way to narrow the choice. Run the MCP server locally instead of using the hosted one:
This one needs Node.js on your PATH, since it launches @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. A FakeVenice 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:
Asserting on the sequence of roles is a good habit for agent code. It catches the malformed-conversation bugs that are otherwise invisible until Venice returns a 400. Three more tests are worth writing, and all of them assert on 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 --yes off during development. Watching which Actors the model wants to run is informative in itself.
  • Use --tools to narrow the catalog to Actors you have actually reviewed.
  • Keep max_rounds modest. 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 Agent is 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 --model and compare tool-selection quality against function_calling_default.
  • Swap the approver for a policy function that auto-approves specific Actors with specific arguments and prompts for everything else.
For a smaller starting point without MCP, Building a Tool-Using Agent covers the same loop with three local Python functions.

Finishing Up

Thanks for reading! Hopefully this helped you build a terminal agent that thinks with Venice and acts through Apify. The pattern worth taking away is how little of this code is about intelligence. The model returns tool calls, and your code decides which ones are allowed to run, how their results come back, and what happens when something fails. Once those decisions are explicit, adding capability is mostly a matter of pointing the agent at more tools.