Building an AI agent that writes its own tools

Today, most AI agents are built by predefining a set of actions (or "tools") they can take: refund a Stripe charge, create an entry in a CRM, send a Slack message.

In fact, many companies are building LLM-specific tooling around their APIs to better connect into LLMs, such as wrappers for Claude and GPT's Tool Use / Function Calling[0].

But we also know that LLMs are pretty good at understanding programming interfaces and writing code on their own.

What if we gave an agent the ability to create its own tools by writing code and executing them safely on Riza using our new Tools API, and avoiding building any LLM-specific tooling at all?

We built a simple demo agent (using Anthropic's Claude 3.5 Sonnet model) that does exactly that, and we were surprised at how well it was able to bootstrap its own skills by writing code on the fly in response to user requests.

We'll walk you through a demo interaction below, but we encourage you to try it out for yourself!

[0] e.g., Stripe

Demo

For demo purposes, we're going to operate on the Stripe API, because it's well known, easy to configure, and offers a safe testmode environment. However, we expect these results to be reproducible on other well-known API services.

On a fresh session, we provide the LLM with just three tools: two to request user input (request_user_input[0], show_options[1]), and one to create a new tool (create_tool[2]).

This demo is a real session I had using our demo agent, formatted for clarity. Take a look at the rightmost column to observe how the agent is growing its own skillset on the fly.

[0] request_user_input definition and implementation

[1] show_options definition and implementation

[2] create_tool definition and implementation

Chat messages SelfLearningAgent client Agent's toolset
System prompt

User: You are an autonomous agent that can create tools to perform tasks. You can write Typescript code to create tools. You can also use the tools you have created to perform tasks.

You should use the create_tool tool to create new tools. All tools must have a unique name, including from the available tools.

Each tool should be written in Typescript code, as a function named execute that takes one argument called input. For example:

function execute(input: unknown) {
// ...
}

You must also write the input_schema for the tool, which must be a valid JSON Schema object.

The input argument passed to the execute function will match the schema of the input_schema of the tool.

If you write a tool that uses an external API, you must make plain HTTP requests to the API, using only the node fetch library. You can assume that any authentication is handled outside of the tool, and does not need to be handled in the tool.

When executing tools, you should use the request_user_input tool to request user input if you need to.

Commentary: Note that the system prompt does not contain references to Stripe or any other service; it is generic, so the agent can be shaped into whatever you prompt it to be.
  • request_user_input
  • show_options
  • create_tool
User: list all stripe customers with the email david@riza.io
Assistant: To accomplish this task, we need to create a tool that can interact with the Stripe API to list customers with a specific email. Then, we'll use that tool to fetch the customers with the email "david@riza.io". Let's start by creating the tool.

Assistant: [Tool Use] create_tool

View tool use args
create_tool({
name: "list_stripe_customers_by_email",
description: "List Stripe customers filtered by email address",
input_schema: {
type: "object",
properties: {
email: {
type: "string",
format: "email",
description: "The email address to filter customers by",
},
},
required: [ "email" ],
},
code: "async function execute(input: { email: string }): Promise {\n const apiUrl = 'https://api.stripe.com/v1/customers';\n const params = new URLSearchParams({\n email: input.email,\n limit: '100' // Adjust this value if needed\n });\n\n try {\n const response = await fetch(${apiUrl}?${params.toString()}, {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded'\n }\n });\n\n if (!response.ok) {\n throw new Error(HTTP error! status: ${response.status});\n }\n\n const data = await response.json();\n return data.data; // Return the list of customers\n } catch (error) {\n console.error('Error fetching Stripe customers:', error);\n throw error;\n }\n}",
}
Commentary: The code block of this tool use contains the LLM-generated code to perform the task. The LLM also wrote the tool's input schema and descriptions.
Handle use of create_tool by calling Riza's Create Tool API

User: [Tool Use Result]

Created tool list_stripe_customers_by_email

View LLM-generated Tool source code
async function execute(input: { email: string }): Promise {
const apiUrl = 'https://api.stripe.com/v1/customers';
const params = new URLSearchParams({
email: input.email,
limit: '100' // Adjust this value if needed
});

try {
const response = await fetch(`${apiUrl}?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();
return data.data; // Return the list of customers
} catch (error) {
console.error('Error fetching Stripe customers:', error);
throw error;
}
}
  • request_user_input
  • show_options
  • create_tool
  • (new!) list_stripe_customers_by_email
Commentary: The tool that the LLM just created is now available for it to use.
Assistant: Great! We have successfully created a tool called list_stripe_customers_by_email that can fetch Stripe customers filtered by email address. Now, let's use this tool to list all Stripe customers with the email "david@riza.io".

Assistant: [Tool Use] list_stripe_customers_by_email

list_stripe_customers_by_email({
email: "david@riza.io",
})
Handle use of list_stripe_customers_by_email by calling Riza's Execute Tool API on the previously created tool, and return the output of the Riza execution

User: [Tool Use Result]

View full response
[
{
id: "cus_RFmj2hHH2mxihm",
object: "customer",
address: null,
balance: 0,
created: 1732121628,
currency: null,
default_source: null,
delinquent: false,
description: null,
discount: null,
email: "david@riza.io",
invoice_prefix: "D9B5DD75",
invoice_settings: {
custom_fields: null,
default_payment_method: null,
footer: null,
rendering_options: null,
},
livemode: false,
metadata: {},
name: "David 2",
next_invoice_sequence: 1,
phone: null,
preferred_locales: [],
shipping: null,
tax_exempt: "none",
test_clock: null,
}, {
id: "cus_RFSb5bMYLIijjp",
object: "customer",
address: null,
balance: 0,
created: 1732046750,
currency: null,
default_source: null,
delinquent: false,
description: null,
discount: null,
email: "david@riza.io",
invoice_prefix: "68F40A79",
invoice_settings: {
custom_fields: null,
default_payment_method: null,
footer: null,
rendering_options: null,
},
livemode: false,
metadata: {},
name: "David Test",
next_invoice_sequence: 1,
phone: null,
preferred_locales: [],
shipping: null,
tax_exempt: "none",
test_clock: null,
}
]

Assistant: I've successfully retrieved the Stripe customers associated with the email address "david@riza.io". Here's a summary of the results:

  1. Customer 1:
    • Name: David 2
    • ID: cus_RFmj2hHH2mxihm
    • Created: 1732121628 (Unix timestamp)
    • Email: david@riza.io
  2. Customer 2:
    • Name: David Test
    • ID: cus_RFSb5bMYLIijjp
    • Created: 1732046750 (Unix timestamp)
    • Email: david@riza.io

Both customers have the same email address (david@riza.io) as requested. They appear to be test customers (livemode: false) and have no balance or default payment source set.

Is there any specific information about these customers you'd like to know more about, or would you like to perform any other actions with this data?

Assistant: [Tool Use] request_user_input

Handle use of request_user_input by prompting for user input in the CLI, and return the response

User: [Tool Use Result]

change the email of the first one to david+demo@riza.io
Assistant: To change the email of the first customer, we need to create a new tool that can update a Stripe customer's email. Then, we'll use this tool to change the email of the customer with ID "cus_RFmj2hHH2mxihm". Let's start by creating the update tool.

Assistant: [Tool Use] create_tool

View tool use args
create_tool({
name: "update_stripe_customer_email",
description: "Update a Stripe customer's email address",
input_schema: {
type: "object",
properties: {
customerId: {
type: "string",
description: "The ID of the Stripe customer to update",
},
newEmail: {
type: "string",
format: "email",
description: "The new email address for the customer",
},
},
required: [ "customerId", "newEmail" ],
},
code: "async function execute(input: { customerId: string; newEmail: string }): Promise {\n const apiUrl = https://api.stripe.com/v1/customers/${input.customerId};\n const params = new URLSearchParams({\n email: input.newEmail\n });\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded'\n },\n body: params\n });\n\n if (!response.ok) {\n throw new Error(HTTP error! status: ${response.status});\n }\n\n const data = await response.json();\n return data; // Return the updated customer object\n } catch (error) {\n console.error('Error updating Stripe customer:', error);\n throw error;\n }\n}",
}
)
Handle use of create_tool by calling Riza's Create Tool API

User: [Tool Use Result]

Created tool update_stripe_customer_email

View LLM-generated Tool source code
async function execute(input: { customerId: string; newEmail: string }): Promise {
const apiUrl = `https://api.stripe.com/v1/customers/${input.customerId}`;
const params = new URLSearchParams({
email: input.newEmail
});

try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();
return data; // Return the updated customer object
} catch (error) {
console.error('Error updating Stripe customer:', error);
throw error;
}
}
  • request_user_input
  • show_options
  • create_tool
  • list_stripe_customers_by_email
  • (new!) update_stripe_customer_email
Assistant: Great! We have successfully created a tool called update_stripe_customer_email that can update a Stripe customer's email address. Now, let's use this tool to change the email of the first customer (ID: cus_RFmj2hHH2mxihm) to "david+demo@riza.io".

Assistant: [Tool Use] update_stripe_customer_email

update_stripe_customer_email({
customerId: "cus_RFmj2hHH2mxihm",
newEmail: "david+demo@riza.io",
})
Handle use of update_stripe_customer_email by calling Riza's Execute Tool API on the previously created tool, and return the output of the Riza execution

User: [Tool Use Result]

View full response
{
id: "cus_RFmj2hHH2mxihm",
object: "customer",
address: null,
balance: 0,
created: 1732121628,
currency: null,
default_source: null,
delinquent: false,
description: null,
discount: null,
email: "david+demo@riza.io",
invoice_prefix: "D9B5DD75",
invoice_settings: {
custom_fields: null,
default_payment_method: null,
footer: null,
rendering_options: null,
},
livemode: false,
metadata: {},
name: "David 2",
next_invoice_sequence: 1,
phone: null,
preferred_locales: [],
shipping: null,
tax_exempt: "none",
test_clock: null,
}

Assistant: The email address for the customer has been successfully updated. Here's a summary of the changes:

  1. Customer ID: cus_RFmj2hHH2mxihm
  2. Name: David 2
  3. Old Email: david@riza.io
  4. New Email: david+demo@riza.io

The update was successful, and the customer's email address has been changed to "david+demo@riza.io" as requested. All other customer details remain the same.

Is there anything else you would like me to do with this customer's information or any other Stripe-related tasks?

Assistant: [Tool Use] request_user_input

Handle use of request_user_input by prompting for user input in the CLI, and return the response

User: [Tool Use Result]

add a new card to this customer, using a card token
Assistant: I apologize, but I realize we don't have a specific tool to add a new card to a customer using a card token. We'll need to create a new tool for this purpose. Let's create a tool that can add a new card to a Stripe customer using a card token.

Assistant: [Tool Use] create_tool

View tool use args
create_tool({
name: "add_card_to_stripe_customer",
description: "Add a new card to a Stripe customer using a card token",
input_schema: {
type: "object",
properties: {
customerId: {
type: "string",
description: "The ID of the Stripe customer",
},
cardToken: {
type: "string",
description: "The card token generated by Stripe",
},
},
required: [ "customerId", "cardToken" ],
},
code: "async function execute(input: { customerId: string; cardToken: string }): Promise {\n const apiUrl = https://api.stripe.com/v1/customers/${input.customerId}/sources;\n const params = new URLSearchParams({\n source: input.cardToken\n });\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded'\n },\n body: params\n });\n\n if (!response.ok) {\n throw new Error(HTTP error! status: ${response.status});\n }\n\n const data = await response.json();\n return data; // Return the newly added card object\n } catch (error) {\n console.error('Error adding card to Stripe customer:', error);\n throw error;\n }\n}",
})
Handle use of create_tool by calling Riza's Create Tool API

User: [Tool Use Result]

Created tool add_card_to_stripe_customer

View LLM-generated Tool source code
async function execute(input: { customerId: string; cardToken: string }): Promise {
const apiUrl = `https://api.stripe.com/v1/customers/${input.customerId}/sources`;
const params = new URLSearchParams({
source: input.cardToken
});

try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();
return data; // Return the newly added card object
} catch (error) {
console.error('Error adding card to Stripe customer:', error);
throw error;
}
}
  • request_user_input
  • show_options
  • create_tool
  • list_stripe_customers_by_email
  • update_stripe_customer_email
  • (new!) add_card_to_stripe_customer

Assistant: Excellent! We've successfully created a new tool called add_card_to_stripe_customer that can add a new card to a Stripe customer using a card token.

Now, to add a new card to the customer (ID: cus_RFmj2hHH2mxihm), we need a card token. Card tokens are typically generated on the client-side using Stripe.js or a mobile SDK, and they are single-use tokens. As we don't have a real card token available, we'll need to ask the user to provide one.

Let's request the card token from the user:

Assistant: [Tool Use] request_user_input

Handle use of request_user_input by prompting for user input in the CLI, and return the response

User: [Tool Use Result]

tok_mastercard
Assistant: Thank you for providing the card token. Now, let's use our new tool to add this card to the customer's account.

Assistant: [Tool Use] add_card_to_stripe_customer

add_card_to_stripe_customer({
customerId: "cus_RFmj2hHH2mxihm",
cardToken: "tok_mastercard",
})
Handle use of add_card_to_stripe_customer by calling Riza's Execute Tool API on the previously created tool, and return the output of the Riza execution

User: [Tool Use Result]

View full response
{
id: "card_1QNHCCFSuVk36MVyHm2ic5xn",
object: "card",
address_city: null,
address_country: null,
address_line1: null,
address_line1_check: null,
address_line2: null,
address_state: null,
address_zip: null,
address_zip_check: null,
brand: "MasterCard",
country: "US",
customer: "cus_RFmj2hHH2mxihm",
cvc_check: "pass",
dynamic_last4: null,
exp_month: 11,
exp_year: 2025,
fingerprint: "bz2U3AHrSTbgYRuP",
funding: "credit",
last4: "4444",
metadata: {},
name: null,
tokenization_method: null,
wallet: null,
}

Assistant: Great news! The new card has been successfully added to the customer's account. Here's a summary of the added card:

  • Card ID: card_1QNHCCFSuVk36MVyHm2ic5xn
  • Brand: MasterCard
  • Last 4 digits: 4444
  • Expiration: 11/2025
  • Country: US
  • Funding: Credit
  • Customer ID: cus_RFmj2hHH2mxihm (confirming it's added to the correct customer)

The card has been successfully added to the customer's account and can now be used for future transactions. The CVC check passed, which is a good sign, although it's worth noting that for a real card, address verification (address_line1_check and address_zip_check) might also be important.

Is there anything else you'd like me to do with this customer's account or any other Stripe-related tasks?

Assistant: [Tool Use] request_user_input

Handle use of request_user_input by prompting for user input in the CLI, and return the response

User: [Tool Use Result]

charge this customer $20 using the new card
Assistant: I understand you want to charge this customer $20 using the new card we just added. However, we don't have a pre-existing tool to charge a customer. Let's create a new tool for this purpose.

Assistant: [Tool Use] create_tool

View tool use args
create_tool({
name: "charge_stripe_customer",
description: "Charge a Stripe customer using a specific card",
input_schema: {
type: "object",
properties: {
customerId: {
type: "string",
description: "The ID of the Stripe customer",
},
amount: {
type: "number",
description: "The amount to charge in cents",
},
currency: {
type: "string",
description: "The currency for the charge",
},
cardId: {
type: "string",
description: "The ID of the card to use for the charge",
},
},
required: [ "customerId", "amount", "currency", "cardId" ],
},
code: "async function execute(input: { customerId: string; amount: number; currency: string; cardId: string }): Promise {\n const apiUrl = 'https://api.stripe.com/v1/charges';\n const params = new URLSearchParams({\n customer: input.customerId,\n amount: input.amount.toString(),\n currency: input.currency,\n source: input.cardId\n });\n\n try {\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded'\n },\n body: params\n });\n\n if (!response.ok) {\n throw new Error(HTTP error! status: ${response.status});\n }\n\n const data = await response.json();\n return data; // Return the charge object\n } catch (error) {\n console.error('Error charging Stripe customer:', error);\n throw error;\n }\n}",
})
Handle use of create_tool by calling Riza's Create Tool API

User: [Tool Use Result]

Created tool charge_stripe_customer

View LLM-generated Tool source code
async function execute(input: { customerId: string; amount: number; currency: string; cardId: string }): Promise {
const apiUrl = 'https://api.stripe.com/v1/charges';
const params = new URLSearchParams({
customer: input.customerId,
amount: input.amount.toString(),
currency: input.currency,
source: input.cardId
});

try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();
return data; // Return the charge object
} catch (error) {
console.error('Error charging Stripe customer:', error);
throw error;
}
}
  • request_user_input
  • show_options
  • create_tool
  • list_stripe_customers_by_email
  • update_stripe_customer_email
  • add_card_to_stripe_customer
  • (new!) charge_stripe_customer

Assistant: Great! We've successfully created a new tool called charge_stripe_customer that can charge a Stripe customer using a specific card.

Now, let's use this tool to charge the customer $20 using the new card we just added. We'll need to convert $20 to cents (2000 cents) for the Stripe API. We'll use the customer ID, card ID, and amount in our request.

Assistant: [Tool Use] charge_stripe_customer

charge_stripe_customer({
customerId: "cus_RFmj2hHH2mxihm",
amount: 2000,
currency: "usd",
cardId: "card_1QNHCCFSuVk36MVyHm2ic5xn",
})
Handle use of charge_stripe_customer by calling Riza's Execute Tool API on the previously created tool, and return the output of the Riza execution

User: [Tool Use Result]

View full response
{
id: "ch_3QNHCqFSuVk36MVy07u3VhK8",
object: "charge",
amount: 2000,
amount_captured: 2000,
amount_refunded: 0,
application: null,
application_fee: null,
application_fee_amount: null,
balance_transaction: "txn_3QNHCqFSuVk36MVy0GmQwOO3",
billing_details: {
address: {
city: null,
country: null,
line1: null,
line2: null,
postal_code: null,
state: null,
},
email: null,
name: null,
phone: null,
},
calculated_statement_descriptor: "Stripe",
captured: true,
created: 1732121804,
currency: "usd",
customer: "cus_RFmj2hHH2mxihm",
description: null,
destination: null,
dispute: null,
disputed: false,
failure_balance_transaction: null,
failure_code: null,
failure_message: null,
fraud_details: {},
invoice: null,
livemode: false,
metadata: {},
on_behalf_of: null,
order: null,
outcome: {
network_advice_code: null,
network_decline_code: null,
network_status: "approved_by_network",
reason: null,
risk_level: "normal",
risk_score: 5,
seller_message: "Payment complete.",
type: "authorized",
},
paid: true,
payment_intent: null,
payment_method: "card_1QNHCCFSuVk36MVyHm2ic5xn",
payment_method_details: {
card: {
amount_authorized: 2000,
authorization_code: null,
brand: "mastercard",
checks: {
address_line1_check: null,
address_postal_code_check: null,
cvc_check: "pass",
},
country: "US",
exp_month: 11,
exp_year: 2025,
extended_authorization: {
status: "disabled",
},
fingerprint: "bz2U3AHrSTbgYRuP",
funding: "credit",
incremental_authorization: {
status: "unavailable",
},
installments: null,
last4: "4444",
mandate: null,
multicapture: {
status: "unavailable",
},
network: "mastercard",
network_token: {
used: false,
},
overcapture: {
maximum_amount_capturable: 2000,
status: "unavailable",
},
three_d_secure: null,
wallet: null,
},
type: "card",
},
receipt_email: null,
receipt_number: null,
receipt_url: "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xUThtWHhGU3VWazM2TVZ5KM2p-LkGMgbqN7woM4g6LBbixYLIQKIMFXBLzID-Osp1bsppLCLcKndbAFCYSSJs_P_T2Nt5F1wRUGt7",
refunded: false,
review: null,
shipping: null,
source: {
id: "card_1QNHCCFSuVk36MVyHm2ic5xn",
object: "card",
address_city: null,
address_country: null,
address_line1: null,
address_line1_check: null,
address_line2: null,
address_state: null,
address_zip: null,
address_zip_check: null,
brand: "MasterCard",
country: "US",
customer: "cus_RFmj2hHH2mxihm",
cvc_check: "pass",
dynamic_last4: null,
exp_month: 11,
exp_year: 2025,
fingerprint: "bz2U3AHrSTbgYRuP",
funding: "credit",
last4: "4444",
metadata: {},
name: null,
tokenization_method: null,
wallet: null,
},
source_transfer: null,
statement_descriptor: null,
statement_descriptor_suffix: null,
status: "succeeded",
transfer_data: null,
transfer_group: null,
}

Assistant: Excellent! The charge has been successfully processed. Here's a summary of the transaction:

  1. Charge ID: ch_3QNHCqFSuVk36MVy07u3VhK8
  2. Amount: $20.00 (2000 cents)
  3. Currency: USD
  4. Status: succeeded
  5. Customer ID: cus_RFmj2hHH2mxihm
  6. Card used: card_1QNHCCFSuVk36MVyHm2ic5xn (MasterCard ending in 4444)
  7. Captured: Yes
  8. Refunded: No
  9. Outcome: Approved by network, risk level normal
  10. Receipt URL: https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xUThtWHhGU3VWazM2TVZ5KM2p-LkGMgbqN7woM4g6LBbixYLIQKIMFXBLzID-Osp1bsppLCLcKndbAFCYSSJs_P_T2Nt5F1wRUGt7

The charge of $20 has been successfully processed and captured from the customer's new card. The transaction was approved by the network and completed without any issues. The customer can access their receipt using the provided receipt URL.

Is there anything else you would like me to do regarding this customer or any other Stripe-related tasks?


I'll terminate the demo here because I think you get the point. But we've basically just created a basic Stripe agent that bootstrapped itself on the fly.

Take a look at the right column. During this session, the agent wrote four of its own skills, and used them. You could easily take a "snapshot" of your agent in a useful session and use it again later, making its tools deterministic.

Theoretically, you could choose to have the agent not save any of its tools and always write its executions from scratch. In fact, that is effectively what this demo shows, since we started from zero.

However, an interesting extension would be to give the agent a modify_tool tool to iterate (improve) on its skills, much like the "Skill Library" in the Voyager agent that also learned autonomously.

We won't get into that in this blog post, but stay tuned for more! And come chat with us in our Discord if you found this interesting!