A concise, modular blueprint you can adapt to any app. I call this a blueprint as itās not a copy-paste but just gives some ideas that might help you get unstuck.
Why this guide exists
I just landed on a multi-step AI agent workflow that Iām happy with and wanted to share with you all as I havenāt seen many solid approaches for this.
Bubble makes dealing with tool calls tricky for two primary reasons - JSON strings are hard to deal with in Bubble, and looping is hard in Bubble. This guide lays out:
- The minimum data model - option sets & types that keep things tidy.
- A repeat-until-done backend workflow that lets the AI call tools in-line.
- A routing pattern so each toolās logic lives in its own custom event and is modular and maintainable.
I wonāt spell out every field and value need to pass for every part, because Iām lazy, and Iāll be the first to say that Iām not the best at explaining everything. However, if youāve gotten this far with AI in Bubble, you can probably read in between the lines to understand the concept of what weāre trying to do here and can get enough of an idea of whatās going on to try it out yourself.
This guide is specifically for OpenAI compatible APIs. I recommend OpenRouter as it allows you to hot-swap models and use exactly the same logic for all AI models and providers. So, you donāt need two sets of logic for both OpenAI and Anthropic, for example.
Core concepts
Concept | What it is | Why it matters |
---|---|---|
Message | One chat turn - System, Assistant, User or a Tool Call response. | We pass a list of messages to a looping backend workflow that will take those messages, generate a response, and stop if itās done, or use a tool and reschedule itself if it needs to pass the tool call result to the AI. |
Tool Call | A Thing we create when the AI wants to use a tool. Holds tool name, arguments (the JSON the AI provided for this tool call), and id. | Lets you store what the AI requested so that we can use it later and in future messages (for each message in the message history, we also need to include any tools that were requested in that message) |
AI Tool (Option Set) | List of tools (name + JSON function schema). You may have an additional attribute which lets you filter so only certain tools are accessible in certain areas etc. | Edit once; Bubble and the prompt stay in sync. Makes it easy to modularly provide access to some/all tools. |
Process AI Response | Backend workflow that: 1) calls the model with list of messages, 2) updates the latest message, 3) runs the tool call and reschedules itself if a tool call was requested | The engine that keeps the conversation going until the AI is satisfied. |
Use Tool (Router) | Custom event that creates a placeholder message and passes the tool callās arguments to the correct per-tool event. | Decouples routing from business logic; adding a new tool is one option + one custom event. |
Data structure
Option Sets
AI Role
Stores the four AI message roles so the frontend can style messages and we can associate each Message with a particular Role.
Display | id |
---|---|
System | system |
Assistant | assistant |
Tool Call | tool |
User | user |
AI Tool
One option per tool. name is what the model calls; function is the raw JSON schema you pass in the API call to OpenRouter/OpenAI as tools.
Display | name | function |
---|---|---|
Query knowledgebase | query_knowledgebase | { ātypeā:āfunctionā, āfunctionā:{ ⦠} } |
Get weather | get_weather | { ātypeā:āfunctionā, āfunctionā:{ ⦠} } |
AI Model
Useful if you want pricing, context limits, or to switch providers with zero API-Connector edits.
Display | id | $/M Input | $/M Output |
---|---|---|---|
Gemini 2.5 Flash | google/gemini-2.5-flash-preview | 0.15 | 0.60 |
Claude 4 Sonnet | anthropic/claude-4-sonnet | 3.00 | 15.00 |
Data Types
Conversation
Field | Type | Notes |
---|---|---|
title | text | - |
Users | List of User | Access control |
Message
Field | Type | Notes |
---|---|---|
Users | List of User | Access control |
content | text | Empty when itās a placeholder for a tool call |
isGenerating | yes/no | Used to show loaders in front-end etc |
error | yes/no | Set to yes to flag an error and show it in the front-end or something |
AI Role | AI Role option | system, assistant, user, or tool |
Tool Call | Tool Call (optional) | Set when the AI requests to use a tool. |
Tool Call
Thereās an argument to be had that we can just store these fields directly on the Message data type, which is fine I guess, but would make it harder to add multiple tool calls in one message in the future etc.
Field | Type | Notes |
---|---|---|
id | text | The id the LLM assigned |
AI Tool | AI Tool (option set) | Which function to run |
arguments | text | Raw JSON string from the model |
Logic overview
- Process AI Response (backend workflow) Input: Conversation, List of Messages
- Call OpenRouter (or OpenAI) with messages, and the available tools.
- Youāll need to :format as text your messages and make sure you handle edge cases appropriately.
- If This Messageās Tool Call is not empty (meaning the AI requested a tool in this message), then we need to pass that tool callās ID/name/arguments into this part of the prompt (docs here, and here).
- Save the modelās reply into a new assistant message.
- If reply includes a tool call ā trigger Use Tool custom event; else stop.
- Use Tool (custom event)
- Create a tool message (isGenerating = yes) so users see āthinkingā¦ā or whatever you want in the front-end.
- Use āonly whenā conditions to make sure we only run the custom event for this tool name.
- Per-tool custom event ā Example: Query Knowledgebase
- Call your own backend workflow with arguments to convert the string into useable JSON.
- Do whatever logic you want (e.g. get embeddings and query Pinecone).
- Return content from the custom event so it can be accessed from Use Tool custom event.
- Use Tool (continued)
- Fill in the previously created messageās content with the returned data from the custom event that ran, and set isGenerating = no.
- Reschedule Process AI Response and pass the updated messages list (as the AI needs to respond again now it has the tool result).
- Loop repeats until no tool is requested.
Diagram (open in new tab)
(sorry itās imperfectly rendered)
Hope this helps!