Tooling with Amazon Bedrock
DEV Community

Tooling with Amazon Bedrock

This post is part of my notes from Formação AWS, a course by Henrylle Maia. It covers a class from Desafio Labs, one of the course's sections, that focuses on Amazon Bedrock. In this class, I continue building the ticket-selling script for the AWS course from my previous article. This time, I add tools to it, giving the agent access to systems and actions that would otherwise be out of its reach. The Tools A tool gives the model a way to get information it has no other means of reaching. Imagine a user asks a financial agent for last quarter's earnings. That data lives in a database, and the only way to get it is to query the earnings and filter them by quarter. Note: Tools aren't limited to reading data. They can also perform actions, > such as sending an email or placing an order. In this post, I only use tools > that return data: one reads from a mock database and the other returns a > payment link. Giving the model direct access to the database would be risky. It could run queries beyond what the user asked for, and we would have no control over what it executes. The safer approach is to write a function that runs a specific, controlled query and returns only the data needed to answer the user. To expose that function to the model, we wrap it in a tool definition: a name, a description of what it does, and the inputs it expects. The agent sends the available tool definitions to the model along with the user's message, so the model knows which tools it can use. Here's what a tool definition looks like for the earnings example, in the format the Bedrock Converse API expects: const toolConfig = { tools: [ { toolSpec: { name: "get_quarterly_earnings", description: "Returns the company's earnings for a given quarter. " + "Use when the user asks about earnings for a specific period.", inputSchema: { json: { type: "object", properties: { year: { type: "integer", description: "Fiscal year, e.g. 2026" }, quarter: { type: "integer", enum: [1, 2, 3, 4], description: "Fiscal quarter" }, }, required: ["year", "quarter"], }, }, }, }, ], }; The model never sees the function itself, only this definition. It reads the description to decide when to call the tool, and the inputSchema (a JSON Schema) to know which inputs to send. When the model decides it needs a tool, it doesn't answer the user right away. Instead, it responds with the tool's name and the inputs to pass to it. The agent runs the matching function with those inputs and sends the result back to the model, which uses it to write the final answer. The Single Tool Example The best way to learn something is to put it into practice. So to see how tools work with the Amazon Bedrock Converse API, I modified the Ticket Clerk Agent to call a tool that checks the status of the user's registration. The code in this section is just a simple implementation to test the usage of tools. The database is just a constant object named STUDENTS . // Students "Database" const STUDENTS = { "m***@email.com": {name: "Mary", ticket: "VIP", status: "confirmed", request: "VP-23842", payment_date: "20/09/2026"}, "j***@email.com": {name: "John", ticket: "Regular", status: "pending", request: "RG-24251"} } For the tool declaration, I defined a constant object, where the tools key has a list of tool specifications. In this example I only have one toolSpec , for a function that should be called when the model wants to check the registration status of the user. It requires an email to be given so it can search for the user. // Declaring the tool for the model const toolConfig = { tools: [ { toolSpec: { name: "check_registration", description: "Check the registration of a student by the email of purchase" + "use when the person asks if their purchase was confirmed, what ticket they have, or the request number" + "only call if the person has already given their email", inputSchema: { json: { type: "object", properties: { email: { type: "string", description: "email used on ticket purchase" }, }, required: ["email"], }, }, }, }, ] } Then I created a constant to work as a lookup table, associating each tool name with its function. So when the model returns the name of the tool it wants to run, the code checks this table and executes the corresponding function. const EXECUTORS = { check_registration: checkRegistration }; The next important part is the tool loop. This is where the agent and the model go back and forth until the model has everything it needs to answer the user. Since it's a bit long, I'll go through it in parts. The loop starts with a guard. The for has no stop condition of its own, so I check the turn counter against MAX_TOOL_TURNS (set to 5) and stop when the limit is reached. This keeps the model from calling tools forever if something goes wrong. Then the agent sends the whole conversation to the model with ConverseCommand , together with the toolConfig , so the model knows which tools it can use. for (let turn = 0; ; turn++) { if (turn >= MAX_TOOL_TURNS ) { console.log(⚠️ Reached the limit of ${MAX_TOOL_TURNS} tool calls. Stopping.); break; } const response = await client.send( new ConverseCommand({ modelId: MODEL_ID, system: [ { text: SYSTEM_PROMPT }, { cachePoint: { type: "default", ttl: "1h" }}, ], messages, toolConfig, inferenceConfig: { maxTokens: 512, temperature: 0.7 }, }) ); ... } When the response comes back, I push the assistant message to the history. Then I check the stopReason . If it isn't "tool_use" , the model is done, so I join the text blocks of the message, print the answer and leave the loop. const assistantMessage = response.output.message; messages.push(assistantMessage); if (response.stopReason !== "tool_use") { const assistantText = assistantMessage.content .filter((b) => b.text) .map((b) => b.text) .join("") process.stdout.write("\nπŸ’³ Assistant:\n"); process.stdout.write(assistantText); break; } If the stopReason is "tool_use" , the model is asking to run one or more tools. The assistant message can have several content blocks, so I go through each one and skip anything that isn't a toolUse . For each tool request, I get the toolUseId , the tool name and its input , look up the function in EXECUTORS and run it. If the model asks for a tool that isn't in the table, the result is an error instead, so the model knows the call failed. Each result goes into a toolResult with the same toolUseId , so the model can match it with its request. const toolResults = []; for (const block of assistantMessage.content) { if (!block.toolUse) continue; const { toolUseId, name, input } = block.toolUse; const executor = EXECUTORS[name]; const result = executor ? executor(input) : { error: Unknown tool: ${name} }; toolResults.push({ toolResult: { toolUseId, content: [{ json: result }], status: executor ? "success" : "error", }, }); } Finally, all the tool results go back together in a single user message. The loop then starts its next turn and sends the updated history to the model, which can either call another tool or write the final answer. messages.push({role: "user", content: toolResults }); The full code is in this gist. Results For a first test, I asked for a registration status, and then the agent asked for my email and returned the correct data: Then I asked for the registration status of a user that is not in the database: So we can see that in both cases, the tool was called and provided the model with enough information for it to return the answer to the user. Tool Chaining Now, I added a new tool that generates a link for payment. But before generating the link, the model is instructed to call check_registration to check whether the user has already bought a ticket. First I created the function to generate and return the payment link, if the email is in a valid format. In this example I am just returning a constant value, but in production, this function would generate a custom payment link. const PAYMENT_LINK = "https://formacaoaws.com.br/payment-link"; function genPaymentLink({ email }) { const clean = (email ?? "").trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+.[^\s@]+$/.test(clean)) { return { generated: false, reason: "invalid email" }; } return { generated: true, email: clean, link: PAYMENT_LINK }; } Then I added the new tool to toolConfig , so now the model has both tools to choose from. const toolConfig = { tools: [ { toolSpec: { name: "check_registration", description: "Check the registration of a student by the email of purchase " + "use when the person asks if their purchase was confirmed, what ticket they have, or the request number " + "only call if the person has already given their email ", inputSchema: { json: { type: "object", properties: { email: { type: "string", description: "email used on ticket purchase", }, }, required: ["email"], }, }, }, }, { toolSpec: { name: "gen_payment_link", description: "Generates ticket payment link. " + "Use it when the person wants to buy, pay or complete the purchase. " + "Only call this after the user provides their email. " + "MANDATORY before calling this tool: call check_registration with the same email. " + "If it returns status 'confirmed', DO NOT call this tool, the user has already paid; " + "Inform them of this and provide the order number. " + "Only generate the link if the registration does not exist or is not confirmed.", inputSchema: { json: { type: "object", properties: { email: { type: "string", description: "Email for sending the payment link", }, }, required: ["email"], }, }, }, }, ], }; I also added it to the EXECUTORS object. const EXECUTORS = { check_registration: checkRegistration, gen_payment_link: genPaymentLink, }; The full code for this version is in this gist. Results For the first test, I said I wanted to buy a ticket and provided an email that is not registered. As you can see in the image below, both tools were called from just one prompt. The first one checked if the user existed, and the second one generated and returned the payment link. When I asked to buy a ticket, providing

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.