from fastcore.test import test_eq, test_failclaude
claude CLI - an agent with its own harness, not a completion endpoint.
The wire
One turn is one query() or one claude -p process. Claude Code has a real system-prompt channel, so unlike Cursor the briefing goes there and only the conversation is rendered into the prompt.
The tools are the point. Claude Code declares a caller’s tools to the model as an in-process MCP server, and an organisation-managed configuration forbids every dynamic MCP server there is - so on a managed machine that path leaves the model with no tools at all. This backend never opens one: it declares an empty MCP configuration on both paths and the schemas go out as tags in the system prompt, which parse_tool_tags reads back off the reply. A managed policy has nothing to refuse.
What it must not do is claim --strict-mcp-config / strict_mcp_config=True. That flag is refused outright where an enterprise configuration exists - “You cannot use –strict-mcp-config when an enterprise MCP config is present” - so the obvious way to say “only my servers, please” is the one shape a managed machine rejects. Declare nothing, claim nothing.
norm_claude
def norm_claude(
d, model:NoneType=None
):A Claude Code result -> a rishi Resp, with <tool_call> tags read out of the text.
norm_claude_usage
def norm_claude_usage(
u, model:NoneType=None
):Claude Code’s usage block -> rishi’s, so a Claude turn adds up with a local one.
claude_via
def claude_via(
via:NoneType=None
):Which path to take: what you named, else the SDK when it is installed, else the CLI.
sdk_available
def sdk_available():Is the Claude Agent SDK importable here?
claude_bin
def claude_bin(
bin:str='claude'
):Absolute path to the claude binary, or a FileNotFoundError that says how to get one.
test_eq(claude_via('cli'), 'cli')
test_fail(lambda: claude_via('rest'), contains='must be')
r = norm_claude({'result': 'ok\n<tool_call>\n{"name": "ls", "arguments": {"path": "."}}\n</tool_call>',
'usage': {'input_tokens': 10, 'output_tokens': 5, 'cache_read_input_tokens': 90}}, opus5)
test_eq(resp_text(r), 'ok')
test_eq(r['tool_calls'][0]['function']['name'], 'ls')
test_eq(r['usage']['total_tokens'], 105) # cache reads are prompt tokens too
test_fail(lambda: norm_claude({'is_error': True, 'result': 'nope'}), contains='nope')The chat
ClaudeChat
def ClaudeChat(
model:NoneType=None, runtime:NoneType=None, model_path:NoneType=None, sp:str='', messages:NoneType=None,
tools:NoneType=None, ctx_limit:NoneType=None, approve:NoneType=None, tool_max_len:NoneType=None,
max_steps:int=10, parallel_tools:bool=False, max_parallel_tools:NoneType=None,
final_prompt:str="You've reached the tool-call budget for this turn. Stop calling tools and answer with what you already have.",
permission_mode:str='auto', # Claude Code's gate on its *own* tools; yours are rishi's
claude_tools:NoneType=None, # Claude Code's own tools, allowlisted; None -> its default
claude_disallowed:tuple=('Bash', 'Write', 'Edit', 'NotebookEdit'), # ...and the ones it may never use
workspace:NoneType=None, # directory Claude Code works in; None -> the cwd
effort:NoneType=None, # 'low'/'medium'/'high'/'xhigh'/'max'; None -> the default
via:NoneType=None, # 'sdk' or 'cli'; None -> the SDK when it is installed
bin:str='claude', timeout:int=600, settings:NoneType=None, cbs:NoneType=None, default_cbs:bool=True, **opts
):Chat against a Claude Code model - the same rishi.core.Chat API, over the Agent SDK or the CLI.
The CLI path
claude -p with --output-format json. --strict-mcp-config with an empty --mcp-config is the whole enterprise story: no dynamic server is declared, so no policy has anything to refuse.
The SDK path
claude_agent_sdk.query is one turn, asynchronous, yielding messages. The options carry the same refusal to open an MCP server that _cmd does.
The two steps ToolLoopMixin drives
Tests
Nothing here starts a model: what is asserted is the command line and the options - which is where the enterprise contract lives, and the part that fails silently if it regresses.
def _fn(query: str) -> str:
"Search the code."
return ''
c = ClaudeChat('claude/claude-opus-5', sp='be brief', tools=[_fn], via='cli',
claude_disallowed=('Bash',))
test_eq(c.model_id, 'claude-opus-5') # the `claude/` prefix is stripped, the id is not
test_eq(c.local, False)
cmd = c._cmd('json')
# The enterprise contract: no dynamic server is declared, and `--strict-mcp-config` is *not* claimed.
# A managed machine refuses that flag outright, so asking for it is how this path used to fail.
test_eq(cmd[cmd.index('--mcp-config') + 1], NO_MCP)
test_eq('--strict-mcp-config' in cmd, False)
test_eq(cmd[cmd.index('--model') + 1], 'claude-opus-5')
test_eq(cmd[cmd.index('--disallowed-tools') + 1], 'Bash')
# and the prompt is not on the command line at all: `--disallowed-tools` is variadic and would eat it
test_eq(c._prompt() in cmd, False)
# ...so the schemas have to be somewhere, and the system prompt is where
sp = cmd[cmd.index('--system-prompt') + 1]
test_eq('be brief' in sp and '"_fn"' in sp and '<tool_call>' in sp, True)
# and the briefing is *not* also in the prompt, which has a channel of its own here
test_eq('be brief' not in c._prompt(), True)# The same contract on the SDK path. `eval: false` only because it needs the SDK installed.
o = ClaudeChat('claude/claude-opus-5', sp='be brief', tools=[_fn], via='sdk')._opts('be brief')
test_eq(o.mcp_servers, {})
test_eq(o.strict_mcp_config, False)
test_eq(o.max_turns, None)
test_eq(o.system_prompt, 'be brief')