Skip to content

feat: Claude code execution tool and programmatic tool calling - #1020

Closed
klin333 wants to merge 83 commits into
tidyverse:mainfrom
klin333:feature/claude-ptc
Closed

feat: Claude code execution tool and programmatic tool calling#1020
klin333 wants to merge 83 commits into
tidyverse:mainfrom
klin333:feature/claude-ptc

Conversation

@klin333

@klin333 klin333 commented Jun 12, 2026

Copy link
Copy Markdown

Summary

This PR adds support for Anthropic's server-side code execution tool and programmatic tool calling (PTC) to ellmer.

  • claude_tool_code_execution(): a built-in tool definition that enables Claude's server-side code execution sandbox, including downloading/handling of output files, container reuse across conversation turns, and expired-container recovery.
  • tool(allowed_callers = ...): lets a client-defined tool be invoked programmatically from inside Claude's code execution environment, not just by the model directly. Tool results are serialized back into the sandbox.

The headline capability is the combination of the two: with programmatic tool calling, data returned by client tools (e.g. a SQL query tool) flows directly into the code execution sandbox without passing through the model's context. Without PTC, the model has to regurgitate tool output token-by-token before any code can use it — prohibitively token-inefficient for things like large SQL result sets. With PTC, Claude writes code that calls the tool, receives the result inside the sandbox, and only the final computed answer needs to surface to the model.

Supporting work included here:

  • Streaming support for programmatic tool calls, including paused/resumed tool-call turns.
  • Prompt-caching breakpoints that remain valid when a conversation ends in a programmatic tool result.
  • Conversation state rollback when chat()/stream() fails mid-tool-loop, so a failed request doesn't corrupt the turn history.
  • Replay of server-side tool content on providers that don't support it (OpenAI-compatible, DeepSeek), so a conversation started on Claude can be continued elsewhere without crashing.
  • Foundation fix (983f025): extra_args$tools is now appended rather than merged with utils::modifyList(), which silently dropped unnamed list entries.

Minimal example

A local R tool returns an employee's expense rows as JSON. Opting it in via allowed_callers lets Claude's code call it from inside the sandbox:

library(ellmer)

get_expenses <- function(employee) {
  expenses <- data.frame(
    employee = c("Emma", "Emma", "Victor", "Tara", "Kai", "Kai"),
    amount = c(2500, 2700, 4100, 1200, 1500, 900),
    category = c("travel", "lodging", "equipment", "software", "software", "office")
  )
  jsonlite::toJSON(
    expenses[expenses$employee == employee, ],
    dataframe = "rows",
    auto_unbox = TRUE
  )
}

expense_tool <- tool(
  get_expenses,
  description = paste(
    "Look up an employee's expense line items.",
    "Returns a JSON array of objects with fields:",
    "employee (string), amount (number), category (string)."
  ),
  arguments = list(employee = type_string()),
  # Opt in to programmatic tool calling: Claude's *code* may call this tool
  # from inside the code-execution sandbox, not just the model directly.
  allowed_callers = "code_execution_20250825"
)

chat <- chat_anthropic(model = "claude-sonnet-4-6", echo = "all")
chat$register_tool(claude_tool_code_execution())
chat$register_tool(expense_tool)

chat$chat("Which of Emma, Victor, Tara, Kai exceeded their $5000 total expense budget?")
Live transcript (echo output, abridged)

Claude writes Python that calls the R tool four times in parallel inside the sandbox; ellmer executes get_expenses() locally for each call and returns the rows to the sandbox, not to the model's context.

> Which of Emma, Victor, Tara, Kai exceeded their $5000 total expense budget?
< I'll look up the expenses for all four employees at the same time!
< [code execution]:
<
< import asyncio
<
< async def main():
<     results = await asyncio.gather(
<         get_expenses({"employee": "Emma"}),
<         get_expenses({"employee": "Victor"}),
<         get_expenses({"employee": "Tara"}),
<         get_expenses({"employee": "Kai"})
<     )
<     for name, result in zip(["Emma", "Victor", "Tara", "Kai"], results):
<         print(f"{name}: {result}")
<
< asyncio.run(main())
<
< [tool request (toolu_01…vnet)]: get_expenses(employee = "Emma")
< [tool request (toolu_01…8zh8)]: get_expenses(employee = "Victor")
< [tool request (toolu_01…kAGp)]: get_expenses(employee = "Tara")
< [tool request (toolu_01…segH)]: get_expenses(employee = "Kai")
[tool result  (toolu_01…vnet)]: [{"employee":"Emma","amount":2500,"category":"travel"},{"employee":"Emma","amount":2700,"category":"lodging"}]
[tool result  (toolu_01…8zh8)]: [{"employee":"Victor","amount":4100,"category":"equipment"}]
[tool result  (toolu_01…kAGp)]: [{"employee":"Tara","amount":1200,"category":"software"}]
[tool result  (toolu_01…segH)]: [{"employee":"Kai","amount":1500,"category":"software"},{"employee":"Kai","amount":900,"category":"office"}]
< [code execution result] (exit 0)
< Emma: [{"employee":"Emma","amount":2500,...},{"employee":"Emma","amount":2700,...}]
< Victor: [{"employee":"Victor","amount":4100,...}]
< Tara: [{"employee":"Tara","amount":1200,...}]
< Kai: [{"employee":"Kai","amount":1500,...},{"employee":"Kai","amount":900,...}]
<
< | Employee | Total Expenses | Over Budget? |
< |----------|---------------|--------------|
< | Emma | $2,500 + $2,700 = **$5,200** | ✅ Yes |
< | Victor | **$4,100** | ❌ No |
< | Tara | **$1,200** | ❌ No |
< | Kai | $1,500 + $900 = **$2,400** | ❌ No |
<
< Only **Emma** exceeded the $5,000 budget, with a total of **$5,200** (over by
< $200) across travel and lodging expenses.

Review scope

This branch builds on the MCP connector work (everything before 983f025, related to #985). For review, focus on changes from 983f025 onward — that is where the code execution / programmatic tool calling work starts.

Test plan

  • testthat suites for provider-claude, tools-built-in, tools-def, chat, chat-tools, provider-deepseek, provider-openai-compatible: 680 passed, 0 failed (29 skipped pending live API keys)
  • Live end-to-end run of the minimal example above against the Anthropic API (parallel programmatic tool calls, streaming)
  • Live end-to-end testing via a Shiny app driving SQL-over-MCP results into code execution

🤖 Generated with Claude Code

kai-lin-cci and others added 30 commits May 19, 2026 13:00
Replaces 7 identical copies of the 11-line ProviderAnthropic
constructor with a shared helper function.
- Add `is_error` and `content` properties to ContentToolResponseMcp so
  callers can distinguish tool errors from successes without digging
  into the raw JSON blob.
- Improve format() to show error/result status and the response text
  instead of just the opaque tool_use_id.
- Drop defensive `%||% ""` fallbacks on required API fields (id, name,
  server_name, tool_use_id) to match existing patterns and surface
  malformed responses loudly.
- Assert @id and @JSON$input on mcp_tool_use (previously untested)
- Assert @is_error and @content on mcp_tool_result
- Add test for mcp_tool_result with is_error = TRUE
Add a `label` parameter to format(ContentToolRequest) and
format(ContentToolResult) so MCP format methods can delegate to them
while using "mcp tool request" / "mcp tool result" labels. MCP output
now shows IDs and arguments, matching the regular tool display style.
Wrap non-list input with list() before passing to ContentToolRequest
constructor, which requires arguments to be a list. Add format tests
for MCP tool request and result display.
Multiline tool results now render with cli_rule header (bold label,
cyan tool ID) and a closing rule, making long results easier to read.
Single-line results keep the inline format.
Record a cassette against the DeepWiki MCP server to test the full
round-trip: mcp_tool_use and mcp_tool_result parsing through
chat_anthropic with the mcp-client-2025-11-20 beta header.
Rename ContentToolRequestMcp -> ContentMcpToolRequest and
ContentToolResponseMcp -> ContentMcpToolResult for consistent
naming. Add new ContentMcpListTools class for OpenAI's
mcp_list_tools output (tool discovery caching).
Parse mcp_list_tools, mcp_call, and mcp_approval_request output
types from the OpenAI Responses API. mcp_call is split into a
ContentMcpToolRequest + ContentMcpToolResult pair, reusing the
same S7 classes as Anthropic. mcp_list_tools is preserved in
turn contents for round-trip caching. mcp_approval_request
errors with guidance to use require_approval = "never".
Add server-side MCP tools section to ?chat_openai with usage
example and auth token instructions. Add VCR-recorded integration
test against the DeepWiki MCP server verifying mcp_list_tools,
mcp_call, and message output types are parsed correctly.
Include chat_openai() alongside chat_anthropic() in the MCP
connector NEWS entry.
Add the core infrastructure for provider-hosted MCP server connections.
The McpConnector S7 class stores URL, name, credentials, and extra
provider-specific args. The mcp_connector() constructor validates
inputs and is registered as an exported function.

Updated check_tool() to accept McpConnector objects and added a
check_mcp_connector_tool() generic with a default Provider method
that errors, so unsupported providers fail fast at registration time.
ProviderAnthropic now accepts McpConnector objects via register_tool().
chat_body() extracts connectors to build the mcp_servers body field,
and as_json() returns mcp_toolset tool entries. A chat_request()
override injects the mcp-client-2025-11-20 beta header automatically
when MCP connectors are present, merging with any existing beta
headers.
ProviderOpenAI now accepts McpConnector objects via register_tool().
as_json() maps name to server_label, url to server_url, and
credentials() to authorization. Extra args (require_approval,
allowed_tools, etc.) are merged into the tool entry.
The tools list is named (keyed by tool name), so lapply() preserved
those names on mcp_servers. jsonlite serializes named lists as JSON
objects, but the Anthropic API expects mcp_servers as a JSON array.
ContentMcpToolRequest now inherits from ContentToolRequest and
ContentMcpToolResult inherits from ContentToolResult, letting downstream
consumers rely on existing tool request/result patterns.

Key changes:

- Add mcp_tool_def() helper to create a no-op ToolDef for MCP tools
- Add local_only parameter (default TRUE) to is_tool_request() and
  is_tool_result() so MCP content doesn't enter invoke_tools()
- Add provider-specific as_json() methods for MCP content on both
  ProviderAnthropic and ProviderOpenAI to prevent S7 multi-dispatch
  from falling through to parent ContentToolRequest/Result methods
- Echo MCP tool requests, results, and tool lists during
  echo="output" via maybe_echo_tool() and echo_server_tool_contents()
- Yield MCP content during streaming when yield_as_content=TRUE
- Use "__" separator for MCP tool display names (server__tool)
Add truncation helpers for formatted tool output:
- `truncate_lines()` limits multi-line output to a max number of lines
  with a "[and N more lines...]" suffix
- `truncate_id()` shortens tool IDs longer than 12 chars (first 8 +
  ellipsis + last 4)

`print(Chat)` now passes `max_lines = 5` so tool results are truncated
when reviewing conversation history. `maybe_echo_tool()` is simplified
to use `truncate_lines()` instead of inline truncation logic.
Remove cli_rule() header/footer from format(ContentToolResult) in favor
of the same inline "[tool result (id)]:" header used for single-line
results.
format(ContentToolResult) gains tool_style = c("plain", "reprex") and
show = "value" options. The reprex style renders values with italic
#> prefixed lines. print(Chat) and maybe_echo_tool() now use the reprex
style, simplifying maybe_echo_tool() by delegating formatting to
format(). New args use tool_ prefix to avoid conflicts with other
formatting options.
OpenAI can return mcp_call errors as structured objects with `type`
("mcp_tool_execution_error") and `content` (list of content blocks)
instead of a plain string. Parse both forms to extract the error text.
maybe_echo_tool() was hardcoding "tool call" for all tool requests.
Now checks for ContentMcpToolRequest and uses "mcp tool call" instead.
Users no longer need to pass require_approval = "never" explicitly.
The default is set in as_json(ProviderOpenAI, McpConnector) and can
still be overridden via ... in mcp_connector().
kai-lin-cci and others added 26 commits June 4, 2026 15:39
claude_tool_code_execution() now documents that the default
"code_execution_20250825" should be preferred for programmatic tool
calling: the newer "code_execution_20260120" returns "unavailable"
errors when Claude issues several programmatic tool calls in parallel,
which was confirmed against the live API. Also adds the previously
missing man page for this exported function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
link_server_tool_results() only matched server-side tool requests to
their results within a single API response. Programmatic tool calling
pauses a turn after the code-execution request and returns the matching
result in a later turn (once the local tool runs), so the result arrived
without its request and kept an empty @request@name/@arguments. shinychat
builds tool cards from those fields, so the resumed result rendered with
an empty title and call.

Add relink_server_tool_results(), which fills any still-empty result by
searching the whole conversation for the request with the same id, and
call it from TurnAccumulator$value_turn() so it runs before contents are
yielded for display (covering streaming/non-streaming, sync/async).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stream() and stream_async() returned the chat_impl generator directly,
bypassing the rollback added in 960ff1c. A request that failed partway
through the tool loop left a dangling tool-request turn, poisoning every
later call on the chat. Because the generator is iterated by the caller,
the rollback must live in the iteration itself: wrap the generator so it
snapshots the turns when iteration begins and restores them if a chunk
errors. Factor the shared restore-and-re-raise logic into a helper used
by all four rollback wrappers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
text-editor `create` and `str_replace` results carry no stdout, so
code_execution_result_body() produced an empty value and the tool card /
echo rendered nothing. Summarise them instead: `str_replace` shows its
unified-diff `lines` hunk and `create` reports "File created." / "File
updated." from `is_file_update`. `view` results (file content under
`content`) and bash/Python stdout are unchanged. Result shapes confirmed
against the live API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
register_tool() warned "allowed_callers is only supported by Anthropic"
for any non-ProviderAnthropic provider. That message is misleading for
chat_aws_bedrock(), which runs Claude but reaches it through the AWS
Converse API (no programmatic tool calling). Replace the hardcoded
S7_inherits() check in the generic Chat class with a check_programmatic_tool()
provider generic, mirroring check_mcp_connector_tool(): the base Provider
method warns that the field is ignored and points to chat_anthropic(),
while ProviderAnthropic overrides it with a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
last_container_id() resent the freshest container id from history on
every request, even after it had expired. A stale container id isn't
rejected by the API, but code execution then silently fails because the
sandbox is gone. Skip a container whose `expires_at` is in the past so
the API provisions a fresh one instead. Containers without a timestamp
are still treated as usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
as_json(ProviderAnthropic, ContentToolResult) dereferenced x@request@id
unconditionally, so a result whose @request is NULL (reachable from
user-supplied history, since the property allows NULL) aborted turn
serialization with a cryptic "no applicable method for `@` applied to
NULL". Raise an actionable error instead. This also makes the @request
access in last_block_is_programmatic_result() safe, since that line is
only reached for results that serialized successfully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
as_json(ProviderAnthropic, ContentToolRequest) passed @arguments through
as `input`. A bare empty list() serializes to a JSON array (`[]`) under
auto_unbox, but the API requires `input` to be an object; coerce an empty
arguments list to an empty named list (`{}`), mirroring the existing
pattern in provider-aws.R. The round-trip path was unaffected (it parses
`{}` into a named list), but a synthetically constructed argument-free
request would otherwise be rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tool results holding Content objects are normally unrolled into a forward
pointer plus sibling user blocks, but a programmatically-called tool delivers
its result to code running in Claude's sandbox, which only sees the pointer
text. Raise a tool-author-facing error instead of silently sending a useless
placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The API rejects a request that carries a container id without the code
execution tool, so requests that drop the tool (structured extraction,
batches, set_tools()) must not reuse the conversation's container. Also
reuse the new is_programmatic_tool_result() helper in
last_block_is_programmatic_result().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…roviders

An assistant turn replayed to an OpenAI-compatible provider used to echo raw
Anthropic code-execution result blocks into the content array (and put the
request's tool call there too), which chat-completions APIs reject. Route
server-side tool requests (code execution, MCP) into tool_calls and emit
their results as role 'tool' messages. Drop the Provider-level as_json
methods for the Code classes: the request method was shadowed by every
concrete provider's ContentToolRequest method, and the response method was
the source of the raw JSON leak.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…version

?tool recommended code_execution_20260120 while claude_tool_code_execution()
defaults to (and recommends) code_execution_20250825. Recommend the default
everywhere and state that allowed_callers must match the registered tool's
type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The snapshot for 'claude_tool_code_execution() rejects a non-string type' was
never committed, so the error text was not actually verified on a fresh
checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'direct' is the API default and an explicit opt-OUT of programmatic calling,
yet any non-empty allowed_callers triggered the 'enable programmatic tool
calling' warnings at register time and in chat_body(). Both gates now share
an is_programmatic_tool() helper that requires a non-'direct' caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
last_block_is_programmatic_result() re-serialized trailing blocks via
as_json() to mirror the list serializer's compaction, and its is.null() test
already disagreed with compact()'s drop-zero-length semantics. Serialize each
block once, find the last emitted one by index, and inspect the source
content directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l result

The cache breakpoint was skipped entirely when the last block was a
programmatic tool result, so every continuation request in a programmatic
tool-calling loop re-billed the full history uncached. Move breakpoint
placement from the per-turn serializer into chat_body(), which walks the
serialized messages backwards and puts the breakpoint on the newest block
that can carry one (skipping programmatic requests/results, thinking, and
raw server-tool blocks). This also drops the is_last machinery from
as_json(ProviderAnthropic, Turn).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The MCP and code-execution request/result classes were enumerated
independently in is_tool_request(), is_tool_result(), is_server_tool_content(),
and relink_server_tool_results(), and link/relink duplicated the
id-map/copy logic. A server_tool_pairs registry now feeds all of them via
is_server_tool_request()/is_server_tool_result() and a shared
fill_result_requests() helper, so the next server-side tool type is added in
one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rolling back discards turns whose tool calls already executed (so a retry
can re-run non-idempotent tools) and removes their billed token usage from
get_tokens()/get_cost(). Mention both consequences and the
ellmer_preserve_turns_on_error escape hatch in ?Chat and NEWS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ization

The serialization-time abort fired after the tool had already run, so a
side-effecting tool would re-execute on every retry and die the same way,
and a replayed conversation containing such a result failed as_json on every
provider forever. new_tool_result() now converts a rich value from a
programmatic call into an error tool result the model can react to, and
serialization falls back to the normal unrolling for turns recorded before
this check existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
all() over a zero-length logical is TRUE, so a tool returning list() — a
perfectly JSON-convertible empty result — was classified as rich content and
rejected when called programmatically (and pointlessly unrolled into empty
<tool-contents> wrappers when called directly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ProviderDeepSeek's Turn override predates the compat replay fix: an
assistant turn with no text (the paused programmatic-call shape) crashed
as_json dispatch on NULL, and server-side requests/results were silently
dropped. Treat them like the compat parent does — requests into tool_calls,
results as tool messages — and skip the assistant message when it would be
empty. The tool-message construction now lives in a shared
tool_result_message() helper instead of four copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw provider-specific mcp_list_tools block was echoed verbatim into the
assistant content array, so any MCP-connector conversation (whose first MCP
use emits one) failed to replay on an OpenAI-compatible provider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A replayed assistant turn containing only server-side tool results (e.g. a
code execution result with no accompanying text) serialized as
{role: 'assistant'} with neither content nor tool_calls, which
chat-completions APIs reject.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified against the live API: a chat_structured() call on a conversation
that used code execution or programmatic tool calling succeeds even though
the request carries neither the code_execution tool nor the container — only
the container field requires the tool to be enabled, and that is already
gated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
any(NA != 'direct') is NA, so registering a tool whose allowed_callers
contained NA killed register_tool() with a bare 'missing value where
TRUE/FALSE needed'. tool() now rejects NA up front, and
is_programmatic_tool() guards with isTRUE() for hand-constructed ToolDefs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chat_request() appends provider@extra_args[['tools']] after chat_body() has
already decided whether to send the container, so enabling code execution
through api_args silently lost container reuse across turns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread R/provider.R
content_text <- function(content) {
switch(
class(content)[1],
switch(class(content)[1],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[air] reported by reviewdog 🐶

Suggested change
switch(class(content)[1],
switch(
class(content)[1],

test_that("ContentToolResponseCode inherits from ContentToolResult", {
res <- ContentToolResponseCode(
value = "ok",
request = ContentToolRequest(id = "srvtoolu_1", name = "", arguments = list()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[air] reported by reviewdog 🐶

Suggested change
request = ContentToolRequest(id = "srvtoolu_1", name = "", arguments = list()),
request = ContentToolRequest(
id = "srvtoolu_1",
name = "",
arguments = list()
),

turn2 <- AssistantTurn(list(
ContentToolResponseCode(
value = "2",
request = ContentToolRequest(id = "srvtoolu_1", name = "", arguments = list()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[air] reported by reviewdog 🐶

Suggested change
request = ContentToolRequest(id = "srvtoolu_1", name = "", arguments = list()),
request = ContentToolRequest(
id = "srvtoolu_1",
name = "",
arguments = list()
),

turn2 <- AssistantTurn(list(
ContentMcpToolResult(
value = "ok",
request = ContentToolRequest(id = "mcptoolu_1", name = "", arguments = list()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[air] reported by reviewdog 🐶

Suggested change
request = ContentToolRequest(id = "mcptoolu_1", name = "", arguments = list()),
request = ContentToolRequest(
id = "mcptoolu_1",
name = "",
arguments = list()
),

@thisisnic

Copy link
Copy Markdown
Collaborator

Thanks for the PR @klin333.

I'm going to close it for now, as it's not reviewable in its current state being based off multiple unmerged PRs.

Please could you wait for the other PRs to merge before submitting a new one? At this point, it's unclear what changes will be needed to the other PRs, and so you might need to totally rewrite your code here if they change drastically or don't get merged in the end.

Once they've merged, then you can rebase off the main branch and submit a new PR.

When you do submit a new PR, would you mind writing the PR description yourself? Claude generates a lot of content, and it'd be more useful to hear your own summary about what you've implemented and the design decisions you've made as you interacted with Claude.

@thisisnic thisisnic closed this Jun 15, 2026
@klin333

klin333 commented Jun 15, 2026

Copy link
Copy Markdown
Author

No problem. Was going to wait but then Hadley posted about giving LLM a custom calculator tool, and thought to make this PR public so people know it’s not far from writing custom calculate tools to having full server side python execution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants