← back

mantisagent

2026

open-source agent SDK for any LLM

reimplementation of the Claude Agent SDK that works with any model: local, open-source, or commercial. write agent code once and run it against Ollama, Groq, Together, Fireworks, or vLLM by changing one string. universal tool-use across models, session persistence, budget controls, MCP integration, and a terminal coding agent included.

one anthropic-shaped api, nine provider adapters, three tool-use paths

  • python
  • asyncio
  • anyio
  • httpx
  • msgspec
  • sse
  • sqlite
  • jsonl
  • mcp
  • json-schema
  • ollama
  • vllm
  • oauth

the problemThe agent loop is not the hard part. The wire formats are.

Anthropic's claude-agent-sdk is a good shape and welded to one vendor. Running the same program against a Qwen on my own GPU box means writing a second agent loop.

A field-renaming translation layer works for about a day. The three worlds do not just spell things differently, they disagree about whether tool calls exist as a channel at all.

mantis-agent-sdk
the PyPI distribution, currently 2.63.0
mantis_agent
the import package, and the drop-in for claude_agent_sdk
mantis
the bundled terminal coding agent, same engine
anthropictool_use block inside the messagetool_result block on the next user turnchannel is typedopenai-compattool_calls array beside contentseparate role: tool message afterchannel is typedopen weightsno tool field anywhereprints the call as prosethen invents the output
where a tool call lives
transportframe readersnormalisedone messageraw bytesraw bytesevent name, datacontent_block_deltachoices[0].deltaInputJsonDeltamessage_deltamake_clienthttpx, retry transportiter_ssehttp.py, msgspec decoder_iter_openai_ssereturns on [DONE]_frame_to_eventsanthropic frame namesevents.py StreamEventseven tagged structs_AssistantAssemblerfeed, then finalizeAssistantMessageblocks, stop_reason, usage
sse bytes to one normalised event type

the ideaResolve a (model, backend) pair to one tool-use path, once

Tool-calling ability belongs to the pair, not to either half. Qwen2.5 emits tool_calls through vLLM and cannot through a bare llama.cpp server, so I made both sides explicit and let one function pick.

The capability table is hand-maintained, which I treat as a feature: nobody publishes a machine-readable answer to whether a checkpoint emits well-formed tool calls, and probing at runtime costs a generation.

open-weight entries
38
hosted entries
24
family defaults
17, for anything unrecognised
A · nativetools[] in the requestserver parses the callmalformed is impossiblefastest, fewest tokensB · taughtprotocol in the system promptparsed out of the text streammalformed is routineschema costs prompt budgetC · taught + grammarB plus guided samplingserver would enforce jsonforbids interleaved prosedowngrades to B today
three ways to get a tool call out of a model
1model rowsupports native, grammar2backend row21 hosted profiles3AND both sides4freeze pathhot loop never re-derives
capability resolution, at Agent construction
registryrequest shapeprovideragent loopinput_schemainput_schematools[], tool_choice autotools[]function.argumentsInputJsonDeltaToolUseBlockawait tool.fnToolRegistry.to_wireskips deferred tools_normalize_tool_defstype: function_normalize_toolskeeps input_schemamodel serverparses the call itself_translate_nativetool_state per index_BlockBuilderjson_parts joinedStreamingToolExecutoradd_tool_callToolResultBlockinto a UserMessage
path A, a native tool call from request to result

how it worksOne turn, from a model name to a tool result

A caller passes model="qwen2.5:7b" and nothing else. The name's shape infers the backend, and profile matching is anchored on purpose: api.deepseek.com matches, a self-hosted box named deepseek-box does not.

On path B the tools are rendered into the system prompt one pretty-printed schema each. I minified that blob once and the failure rate went up, because weak models lose track of which properties belong to which tool.

callersdk coretool-use pathbackendpath Apath Bsse deltasAgent(model=…)no backend givenroutingname to backend urlcapability resolvepath A or Btool executorhooks, permissionstools[] + tool_calls<tool_call> in prompttext stream parsermodel server
one turn, end to end
calleragent looptoolsoutpromptStreamEventAssistantMessagetool_use blockapproved callToolResultBlockstep + 1no tool_use, Stop hookcompat_query.querydrives run_iterAgent.run_iterwhile True, step_provider_streamkwargs, thinking cfg_AssistantAssemblercollects the blocksToolUseBlock scannot stop_reason_preflight_callhook, then permissionStreamingToolExecutordispatch mid streamUserMessageresults in emission orderResultMessageusage, total_cost_usd
the agent loop, one turn and the decision to take another
system prompttext streamparseragent looppretty printed schemastool_call protocoltext deltaraw json between tagsparsed argumentstool_call_stopContentBlockStart_render_prompt_enginee…one schema per tool_build_payloadprepends the preamble_translate_prompt_engi…no tool_calls fieldToolCallTextParsertwo states, 16 char holdb…_loads_lenientfive repair attemptsToolCallStoperror flag on give up_emit_parser_eventback to block eventsToolUseBlockidentical to path A
path B, where the parser sits in the stream

the hard partSmall models lie about tool calls in four different ways

The protocol is five lines of system prompt. Holding a 7B model to it across a long coding session is the whole problem, so most of path B is failure handling.

Truncation taught me the most. A call cut off at max_tokens and a call with a syntax error both arrive unparseable and need opposite advice, and giving the wrong one buys an infinite loop at a full generation per turn.

parser
two states, parses on close, buffers under a kilobyte
split tags
holds back up to 16 chars that prefix <tool_call>
escaping
tool output is escaped so it cannot forge a call
stray quotesgrep "foo" inside a stringprose wrapperchat around the objectinvented nametool that does not existprose calleven on path A
four ways a call comes back wrong
1rawparse as sent2trimfirst balanced {…}3escapestray quotes fixed4escape(trim)5trim(escape)6give upis_error, not an excep…
the repair ladder, tried in order
cut offprefix well-formed, brackets still openran out of max_tokenssay: send less, append the restwrong advice loops forevermalformedbalanced but wrongmodel broke its own jsonsay: re-issue valid jsonwrong advice truncates again
the discriminator that decides what to tell the model

the stackWhat it runs on, and what I refused to depend on

No openai, no anthropic, no ollama client library. Every adapter is raw HTTP against the documented wire format, because a vendor SDK that validates model names against its own allowlist rejects exactly the self-hosted case this exists for.

Retries live in a custom httpx transport, so every provider gets them without knowing. It retries POST, which is normally wrong and is fine here: a completions request has no application-level side effect and no partly-read stream is ever replayed.

surfacesmantis terminal · mantis-agent cliquery() / Agent · claude_agent_sdk shimagent coretool registry · permissions + hooksbudget tracker · sessions + jsonlnormalisationcapability tables · path A/B/C resolver<tool_call> parser · lenient json repairprovidersanthropic messages · openai chat completions · ollamallama.cpp · tgi · bedrock / vertexruntimeanyio · httpx + retry transport · msgspec · sqlite
what sits on what
everything112kmcp client + server5,277 linesopenai_compat1,859 linesanthropic adapter1,098 lines
where the python is
openai_compat1,859 lines, azure routinganthropic/v1/messages passthroughbedrockaws eventstreamvertexgoogle hostingollamalocal tagsllama.cppgbnf grammartgihuggingface servingmodalown gpu jobsmockhow 6,878 tests run
nine provider adapters, no vendor client libraries

parityThe argument the project is actually making

PARITY.md is notes taken against the live Claude Code docs and the deobfuscated CLI, because "a reimplementation" is a claim that has to be checkable. Some of it I copied outright, such as honouring the ultracode keyword only from human-typed input, since that is an injection boundary.

The narrow claim is the one I will defend: every canonical Claude Agent SDK Python example runs verbatim after changing the import line and the options class name. That is a test, not a slogan.

claimedsame query() and @tool surfacesame permission and hook grammarbyte-compatible jsonl transcriptsexamples run after an import swapnot claimedstatuslines, themes, output stylesplugin marketplaceslack and chrome integrationsremote and mobile surfaces
what parity claims
1PreToolUsecan veto2permissiondeny > allow > ask3dispatch4PostToolUse5result to context
what happens to one tool call
per turnone lineon diskresumerole, contentsummary, compacted_countmsgspec, then fsyncload_entriesleaf uuidthe chain, reversedtarget uuidappend_messagerole, content, uuidappend_metacustom-title, last-prompt__compact_boundary__written as a system lineTranscriptEntryparent_uuid threads itprojects/hash/id.jsonlappend only, fsynclatest_leafnewest non sidechainbuild_chainwalks parent_uuidentries_to_messagesdrops dangling tool_usebranch_sessionrestamps uuids
what a turn writes, and what resume reads back

where it standsWhat works, and what I know is wrong

The three-path tool layer, sessions with fork and resume, MCP client and server with OAuth, and the mantis terminal on top all work. Most of the 6,878 tests run against a mock provider, so the wire-format work is testable without a GPU or an API key.

The thing I would rebuild first is the capability probe. Capability is declared today; it should be declared and then verified once per backend with a cheap handshake, cached on disk, so an unknown endpoint gets the good path without me shipping a release.

read_filediskwrite_filediskedit_filediskmulti_editdisknotebook_editdisklsdiskglobdiskgrepdiskbashshellbash_outputshellbash_killshellmonitorshellsleepshelllspcode navtool_searchcode navweb_searchnetworkweb_fetchnetworktodo_writeagent staterememberagent stateload_skillagent stateexit_plan_modecontrolask_user_questioncontrolworkflowcontrol
23 built-in tools, by what they touch
workspaths A and B across 21 profilesfork, resume, jsonl transcripts27 hook events, 7 can vetoper-model usd accountingknown wrongpath C resolves, then downgradestokens estimated as len // 4openrouter prices are sentinel zerosnew checkpoints need a hand-added row
honest ledger
configclientmergeregistryServerConfigStdioServerConfigjsonrpc framestools/list resultMCPToolTool structmcp__server__toolinput_schemaload_mcp_server_configsstdio, sse, httpfilter_untrusted_proje…project scope gateStdioTransportor Http, or SseMCPClientinitialize, then tools/li…MCPManager.connect_allfailures stay per serverMCPTool.to_mantis_agen…wraps call_tool_ns_segmentno forging a namespaceToolRegistry.addduplicate names raiseto_wiresame array as built-ins
an external mcp server's tools reaching the registry