Exam Room · Advanced Generative AI Developer

How to Wire Function Calling Through Bedrock

· 30 min read

Generative AI Development · part of The Exam Room

The situation

An internal productivity assistant for the engineering team needs to do more than answer questions about docs. Common asks:

  • “What’s the status of build #4812?” → call the CI API, fetch the build status, summarise.
  • “Create a Jira ticket for the bug in the login page.” → call Jira, capture the returned ticket ID, confirm.
  • “Page the on-call for the payments team.” → call PagerDuty, trigger the incident.
  • “What did we deploy last week?” → call the deploy-history service, filter, summarise.
  • “Search our wiki for the runbook on database failover.” → call internal search, retrieve top 3, cite.

Five tools. The assistant should know which to call, pass the correct arguments, confirm before destructive ones (paging, ticket creation), and hand back structured results the model can weave into a coherent reply.

The team has Bedrock Claude Sonnet 5 available and familiar. They want to ship something this sprint, keep the tooling changeable (add a sixth tool next sprint, tweak a parameter), and avoid building orchestration that duplicates what the API already offers.

What actually matters

Function calling is a protocol between the model and the caller. The caller advertises a set of tools, each with a name, description, and typed arguments. The model, given a user message, decides whether to produce text, to call a tool, or both. On a tool call, the caller executes the function, feeds the result back to the model, and the model either calls more tools or produces a final response.

The first decision is which surface to call through. Some inference APIs offer tool use as a first-class, model-agnostic primitive, one code path regardless of which model is plugged in. Others require model-specific payload shapes. A layer above those, fully managed agent runtimes take the loop off the caller’s hands entirely, in exchange for a heavier abstraction. The correct level depends on how many tools, how much session state, and how much orchestration the team wants to own.

The second is how tools are described. JSON schema is the lingua franca: each tool has a name, a description, and typed parameters. The quality of the descriptions drives the quality of the model’s tool choice, a description of “Create a Jira ticket” is less helpful than “Create a Jira ticket in the specified project with summary, description, and assignee. Use when the user asks to report a bug, track a task, or file work. Returns the new ticket’s key and URL.”

The third is the execution loop. After the model emits a tool call, the caller parses the call, validates the arguments against the schema, executes the tool, captures the result (or error), packages it back to the model, and re-invokes. The model then either calls another tool or produces a final message. Loop until the model stops calling tools.

The fourth is whether the caller can constrain tool choice, force the model to use a specific tool, or any tool, or none. Useful for structured-output prompts: force a specific tool with a known schema and the response is guaranteed structured.

The fifth is confirmation and side-effect safety. Tools with side effects (creating a ticket, paging a human) should surface to the user for confirmation before the tool actually runs. This is pattern-level, not protocol-level: the loop pauses on “destructive” tool calls and waits for user confirmation.

And observability, which sounds optional until the first bad afternoon. Every tool call, name, arguments, result, duration, is a debug signal. When an assistant does the wrong thing, the trace of tool calls explains why.

What we’ll filter on

  1. Multi-model fit, does this interface work across Claude, Nova, Llama, etc.?
  2. Schema ergonomics, how easy is it to declare tools and parse calls?
  3. Orchestration surface, how much loop code we write vs the platform runs?
  4. Side-effect safety, is there a confirmation gate built in?
  5. Observability, what traces do we get for free?

The landscape

  1. Bedrock Converse API with toolConfig. The unified interface. Pass toolConfig: { tools: [{ toolSpec: { name, description, inputSchema: { json: {...} } } }, ...] } in the request. The model’s response includes output.message.content as a list of content blocks; toolUse blocks have toolUseId, name, and input. The caller executes, returns a toolResult block with the matching toolUseId, and sends the conversation back. Works across Claude, Nova, Llama, Mistral, any Bedrock model that supports tool use. Same code path regardless of model.

  2. Claude-specific via InvokeModel with Anthropic’s Messages payload. Pre-Converse, Claude’s own tool-use schema was accessed through InvokeModel with model-specific body. Still works; Converse wraps it. Direct usage makes sense only when we need Claude-specific features Converse hasn’t exposed yet.

  3. The AgentCore managed harness. Declare the model, the instructions, and the tools, and the harness handles the tool loop, session state, and tracing, adding the AgentA system that wraps an LLM with tools, memory, and a loop, so it can take multi-step actions toward a goal rather than just answering one prompt. runtime between the caller and the model. Tools reach it through a gateway, which publishes Lambda functions and REST APIs as MCP tools, or as inline functions that execute in your own code. For the teams already on it, it suits long-lived conversations with many tools and complex session state; it is heavy for a simple five-tool assistant, and no longer where a new build starts in any case (that path is Bedrock AgentCore, where the loop stays yours).

  4. LangChain tool-use abstractions. Python-side framework wrapping Bedrock tool use. Cleaner developer ergonomics for some patterns (decorator-style tool definitions, structured parsing). Adds a dependency and an abstraction layer between our code and the API.

  5. Custom orchestration around InvokeModel plain text. Parse a structured response (JSON with tool name and args) from the model’s output. Pre-Converse pattern, brittle, no longer recommended.

Evaluation

Side by side

Option Multi-model Schema ergonomics Orchestration Side-effect safety Observability
Converse + toolConfig Native JSON schema in-line Caller writes loop Caller’s job CloudWatch + CloudTrail
InvokeModel (Messages) Claude only Anthropic schema Caller Caller Same
AgentCore harness Any harness model JSON Schema per tool Managed loop Inline function gate Traces built in
LangChain tools Any SDK Decorator-friendly Framework Framework’s Its own + CloudWatch
Custom plain-text Any Brittle Everything Ours Ours

For five tools, a moderate conversational assistant, and a team already using Bedrock Converse elsewhere, the Converse API with toolConfig is the natural fit. It’s multi-model, the schema is declarative, the loop is under ~50 lines, and the observability story is the same as any other Bedrock call. A managed agent runtime is worth it once the tool surface grows to 20+ tools with complex session requirements; for five tools it is overkill.

The tool-use loop, in shape

Bedrock Converse tool-use loop User message "page the on-call for payments" Build Converse request messages + toolConfig (5 tools) bedrock.converse() Claude Sonnet 5 Response content? text vs toolUse blocks text only Return to user stopReason = end_turn toolUse Parse + validate args JSON schema check Destructive tool? if yes → user confirmation Execute tool CI, Jira, PagerDuty, search, deploys append toolResult → next Converse call toolConfig.tools: • get_build_status(build_id: int) • create_jira_ticket(project: str, summary: str, description: str, assignee: str) ← destructive • page_on_call(team: str, urgency: "high"|"low", note: str) ← destructive • get_deploy_history(service: str, since: date) • search_wiki(query: str, top_k: int)
The loop: call Converse, check for tool-use blocks, gate destructive tools on user confirmation, execute, feed the result back, repeat until the model returns a plain text response.

The solution

Tool declarations. Each tool is a JSON spec passed in toolConfig.tools. The description is where the model learns when to use the tool; treat it as PromptThe input you hand to an LLM – system instructions, user message, examples, retrieved documents, tool descriptions, the lot., not documentation. Compare:

Bad: "description": "Create a Jira ticket."

Good: "description": "Create a Jira ticket in the specified project with a summary, description, and assignee. Use when the user asks to file a bug, report an issue, or track a task. Requires the project key (e.g., ENG, SRE) and assignee username. Returns the created ticket's key and URL."

The tool spec also declares inputSchema with typed, constrained parameters. Enums for fixed-value fields (urgency: ["high", "low"]), format strings where sensible (assignee: { type: "string", pattern: "^[a-z]+$" }), required vs optional explicitly marked. The model respects the schema for the most part; schema violations are rare but do happen with weaker models, and the caller should validate before executing.

The loop. In Python:

def run_assistant(user_message, session):
    messages = session.get_history() + [{"role": "user", "content": [{"text": user_message}]}]
    while True:
        resp = bedrock.converse(
            modelId=MODEL_ID,
            messages=messages,
            toolConfig={"tools": TOOL_SPECS},
            system=[{"text": SYSTEM_PROMPT}],
        )
        out_msg = resp["output"]["message"]
        messages.append(out_msg)

        tool_uses = [c["toolUse"] for c in out_msg["content"] if "toolUse" in c]
        if not tool_uses:
            return _extract_text(out_msg)

        tool_results = []
        for call in tool_uses:
            if call["name"] in DESTRUCTIVE_TOOLS:
                if not session.confirm(call):
                    tool_results.append({"toolUseId": call["toolUseId"], "content": [{"text": "user declined"}], "status": "error"})
                    continue
            try:
                result = dispatch(call["name"], call["input"])
                tool_results.append({"toolUseId": call["toolUseId"], "content": [{"json": result}]})
            except Exception as e:
                tool_results.append({"toolUseId": call["toolUseId"], "content": [{"text": str(e)}], "status": "error"})

        messages.append({"role": "user", "content": [{"toolResult": tr} for tr in tool_results]})

50 lines of orchestration; no framework. The model could emit multiple tool calls in one response (parallel tool use), the loop handles that by executing all and returning all results together.

Destructive-tool confirmation. A runtime set (DESTRUCTIVE_TOOLS = {"create_jira_ticket", "page_on_call"}) intercepts tool calls before execution and asks the user to confirm via the session’s UI (a button in chat, a modal, a Slack approve/deny). Only on approval does the actual call run. On denial, a toolResult saying “user declined” goes back; the model apologises and offers alternatives. The confirmation gate is application-layer, not Bedrock-layer. Converse itself doesn’t know which tools are destructive; we do.

Error handling. Tool errors (API timeout, 4xx from Jira, invalid arguments that passed schema but failed at execution) are returned as toolResult with status: "error" and a text explaining what went wrong. The model sees the error and either retries with different arguments, explains to the user what failed, or escalates. Surfacing errors as data lets the model recover; throwing exceptions up the stack stops the conversation.

Tool choice. toolConfig also accepts a toolChoice parameter: "auto" (default, model decides), "any" (force some tool), "tool": {"name": "..."} (force a specific tool). Forcing a specific tool is handy for structured outputs: define a tool with the desired output schema and force it; the model’s response is guaranteed to match the schema.

Observability. Every Converse call emits CloudWatch metrics (latency, token count, error rate). Each tool call is logged at the application layer with the tool name, arguments (redacted if sensitive), result, duration, and the session ID. The trace for one user message might be “user: …; tool_call: get_deploy_history; tool_result: …; tool_call: search_wiki; tool_result: …; assistant: …”, when something goes wrong, this is the debug surface.

What the request body has to look like

Converse hides the differences between model families, but the JSON formatting for Amazon Bedrock API requests still has a shape that has to be right. Four top-level parts carry the work. messages is an ordered list of turns, and they alternate: user, assistant, user, assistant. Each turn’s content is a list of typed blocks rather than a string, and the block types are text, image, document, toolUse and toolResult. The system prompt travels in its own top-level field rather than as a first user turn. inferenceConfig holds temperature, topP, maxTokens and stopSequences.

Conversation formatting for dialog-based applications is where the failures cluster, and they are validation errors thrown before the model sees anything. Two consecutive user turns is the common one: append a toolResult message without first appending the assistant message that requested it and the alternation breaks, which is why the loop above appends out_msg before it appends anything of its own. A toolResult whose toolUseId matches no toolUse in the preceding assistant turn is the second. An image block over the model’s size limit, or in a format that family does not accept, is the third; image and document blocks carry model-specific requirements, so a conversation that is valid against Claude Sonnet 5 is not automatically valid against whatever gets swapped in next.

InvokeModel has no shared shape at all. The body is the provider’s own JSON schema, so Anthropic’s Messages format, Nova’s, Meta’s and Mistral’s differ in field names and in how tools are declared, and a caller written against one of them is a caller written against one model family. Converse exists to remove that, which is the argument for routing a switching layer, anything expected to change models without changing code, through Converse.

SageMaker’s contract is different again. invoke_endpoint takes a serialised payload with an explicit ContentType and Accept, and the container’s own inference handler parses it, so structured data preparation for SageMaker AI endpoints means matching what that handler expects rather than a published API schema. The same fine-tuned model reached through a SageMaker endpoint and through Bedrock takes two different request contracts, and moving between them changes the caller, not just the URL.

What the Lambda behind a tool owes the model

Intelligent tool integrations break at the boundary more often than in the model. Standardized function definitions are what get the right tool called with plausible arguments; reliable tool operations come from what the code behind the tool does with them once it has them. For these five tools that code is a Lambda apiece, whether it is dispatched in process or published through a gateway that fronts it as an MCP tool, and each handler owes the model four things.

Validate every parameter against the declared schema on entry rather than trusting the model to have honoured it. A model will confidently pass a date string where an integer was declared, or a null where a required field was, most often on the call that follows an ambiguous user message. Lambda functions to implement error handling and parameter validation at the top of the handler catch that before anything reaches Jira or PagerDuty.

Return failures as a structured toolResult carrying status: "error" and a short, actionable message: “order id not found, ask the user to confirm it” gives the model its next move, while an exception that escapes the handler kills the turn. The model reads those strings, so they deserve the same care as the tool descriptions.

Make writes idempotent on a caller-supplied key. A retried tool call, whether the retry comes from the model, from the SDK, or from a Lambda invocation that timed out after the write already landed, should not open a second ticket or issue a second refund. Passing the toolUseId through as the idempotency key collapses the retry onto the first result.

Cap the size of what comes back. A search tool that returns fifty full documents hands back a 50k-token payload that has to fit in the same context window as the conversation history, and it displaces the history the model needs to answer well. Return the top few, trimmed, with an identifier the model can pass to a follow-up call when it wants more.

Worked example

User: “The payments team’s on-call, can you page them? We’re seeing 500s on checkout.”

  1. First Converse call. Response: toolUse(name=page_on_call, input={team: "payments", urgency: "high", note: "500s on checkout"}).
  2. Caller sees destructive tool. Surfaces confirmation: “Page payments on-call (high) with note ‘Seeing 500s on checkout’?”
  3. User confirms. Tool executes; PagerDuty returns incident ID INC-4521.
  4. Caller sends back toolResult(toolUseId=..., content={incident_id: "INC-4521", url: "..."}).
  5. Second Converse call. Response: text-only. “I’ve paged the payments on-call with a high-urgency incident (INC-4521). The on-call engineer should acknowledge within 5 minutes.”
  6. Session history now includes the user message, the toolUse, the toolResult, and the final text. Ready for the next turn.

Total time: ~3 seconds of model latency, ~1 second for PagerDuty, ~5 seconds waiting on user confirmation. Total tool calls made that the user couldn’t see in the trace: zero.

What’s worth remembering

  1. Use Converse, not InvokeModel, for tool use. Unified across models, clean schema, future-proof.
  2. Tool descriptions are prompts. Write them like you’re instructing a new team member; the model reads them to decide when to use each tool.
  3. The loop is yours but it’s small. ~50 lines covers the common case; frameworks exist but often aren’t needed.
  4. Destructive tools need a confirmation gate at the application layer. Converse doesn’t know which tools are dangerous; we do.
  5. Return errors as data, not exceptions. The model can recover from a structured error; it can’t recover from a stack trace.
  6. A tool’s contract is its schema plus its error vocabulary. The model reads both, so validate arguments on entry and write error messages it can act on.

Five tools, a 50-line loop, one confirmation gate, CloudWatch metrics for free, and an assistant that can actually do the things engineering asks it to do rather than just explain how to do them. The machinery is less than you think; the description quality matters more than you think.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.