DEV Community

InterSystems Developer for InterSystems

Posted on Originally published at community.intersystems.com

My First Agent Studio: building and testing native IRIS agents with AI Hub and Ollama

What does it take to go from an ObjectScript class to an agent that can call tools, use skills, and delegate part of a task?

I built My First Agent Studio to make that process easier to explore. It includes small native %AI.Agent examples, terminal demos, a dataset, and a browser UI for testing the same agents interactively. Everything except the local model service is packaged in a Docker Compose project.

The project is my contribution to the InterSystems Community Bounty Program — Round 2. It brings together two ideas:

The source is available in the My First Agent Studio repository, that contains the full setup instructions and examples

This article focuses on how the pieces work together.

What the project runs

The agents run inside InterSystems IRIS using the AI Hub while Ollama supplies the LLM. I chose Ollama so developers can try these features without a paid model API or a cloud-provider API key. You still need enough local resources to run your chosen model, in particular, use a tool-capable model for the tool and delegation examples like Qwen 3.8.


Configure an agent, skills and tools through AI HUB

An agent in AI Hub is more than a connection to an LLM. It is the point where we define which model will do the reasoning, which instructions it should follow, and which capabilities it can use while processing a request. 

These capabilities are separated into two main concepts: tools and skills

  • Tools give the agent something it can do, for example, query data stored in IRIS, perform a calculation, or invoke functionality exposed by an MCP server. 
  • Skills give the agent additional instructions for how to approach a particular type of task.

The agent brings these pieces together, as in the following example:

Class Test.Agent Extends %AI.Agent
{
Parameter TOOLSETS = "Test.ToolSet.Local";

Parameter SKILLS = "Test.Skill.Poet,Test.Skill.Echo";

}

There is an important architectural distinction here: the LLM itself does not automatically have access to our IRIS data or application logic. By declaring the toolsets on the agent, we explicitly define the operations that AI Hub can make available to the model. Likewise, declaring a skill does not add another executable operation; it makes a reusable set of instructions available to the agent.

This gives us a useful separation of responsibilities:

  • Agent → who is reasoning and what capabilities are available
  • Tools → what the agent can do
  • Skills → how the agent should approach a task

Defining a skill

A skill is a reusable set of instructions that an agent can load when it needs to perform a particular kind of task. In AI Hub, we can define one by extending %AI.Skill.

For example, the project contains a simple Poet skill:

Class Test.Skill.Poet Extends %AI.Agent.Skill 

{

XData SUMMARY [ MimeType="text/yaml" ]
{
name: talk-like-a-poet
description: Transform responses into poetic verse.
parameters:

  • name: request description: The user's request to be answered in poetic form type: string required: true tags:
  • poetry
  • creative-writing
  • literary-style
  • verse
  • rhyme }

XData INSTRUCTIONS [ MimeType="text/markdown" ]
{
You are a poet. Respond to every request with the grace and precision of verse.
}

}

There are three pieces worth noticing here: 

  • NAME is the identifier AI Hub uses for the skill
  • DESCRIPTION tells the agent what the skill is intended for. 
  • INSTRUCTIONS contains the actual instructions that are added when the skill is activated.

Defining a simple tool and a toolset

A tool gives the agent an operation it can actually execute.

A native IRIS tool can be implemented by extending %AI.Tool and exposing methods as tools. A deliberately simple example would look like this:

Class Test.Tools.Calculator Extends %AI.Tool 

{

Method Add( a As %Numeric, b As %Numeric ) As %Numeric [ WebMethod ]
{
Return a + b
}

}

However, defining the %AI.Tool class is only the first step. Tools are made available to an agent through a ToolSet.

A ToolSet acts as the container that groups related tools and gives AI Hub a unit that can be attached to an agent. For our calculator, we could define a small ToolSet such as:

Class Test.ToolSet.Local Extends %AI.ToolSet 

{

XData ToolSet
{
<ToolSet Name="Local">
<Tool Name="Calculator"
Class="Test.Tools.Calculator"/>
</ToolSet>
}

}

This gives us a simple hierarchy:

Agent



└── ToolSet



└── Tool



└── WebMethod

The distinction is useful as an application grows. A %AI.Tool class contains the implementation of one or more related operations, while a %AI.ToolSet describes the collection of tools that should be exposed together. The agent can then reference the ToolSet rather than having to know about every individual tool implementation:

If the model receives a question such as "What is 15 + 18?", it does not execute ObjectScript itself. AI Hub exposes the tool definition to the model, the model can decide to request Add with the appropriate arguments, AI Hub executes the ObjectScript method, and the result is returned to the model so it can continue generating its answer.

Defining an agent

Beyond tools and skills, an agent also needs to know which model it will use and how AI Hub should access it. AI Hub represents this connection through a provider.

A provider defines how AI Hub communicates with a model service. In this project we use Ollama, running locally, through its OpenAI-compatible API. This allows us to use local models while keeping the interaction with the model behind the AI Hub provider abstraction.

For example, the agent can initialize its provider as follows:

Method %OnInit() As %Status

{

Set sc=##super()

If $$$ISERR(sc) Quit sc

If '$ISOBJECT(..Provider) {
Set base=$SYSTEM.Util.GetEnviron("OLLAMA_BASE_URL")
Set ..Provider=##class(%AI.Provider).Create("openai",{"api_key":"ollama","base_url":(base)})
Set ..Model=$SYSTEM.Util.GetEnviron("OLLAMA_MODEL")
If ..Model="" Quit $$$ERROR($$$GeneralError,"OLLAMA_MODEL is required")
}

If $$$ISERR(sc) Quit sc

Quit $$$OK

}

Bringing all pieces together, we can define an agent as follows:

Class Test.Agent Extends %AI.Agent

{

Parameter TOOLSETS = "Test.ToolSet.Local";

Parameter SKILLS = "Test.Skill.Poet,Test.Skill.Echo";

Method %OnInit() As %Status
{
Set sc=##super()
If $$$ISERR(sc) Quit sc

If '$ISOBJECT(..Provider) {
Set base=$SYSTEM.Util.GetEnviron("OLLAMA_BASE_URL")
Set ..Provider=##class(%AI.Provider).Create("openai",{"api_key":"ollama","base_url":(base)})
Set ..Model=$SYSTEM.Util.GetEnviron("OLLAMA_MODEL")
If ..Model="" Quit $$$ERROR($$$GeneralError,"OLLAMA_MODEL is required")
}

If $$$ISERR(sc) Quit sc
Quit $$$OK
}

}

Connecting an external MCP server

Not every tool available to an agent has to be implemented in ObjectScript.

AI Hub can also use tools provided by an external Model Context Protocol (MCP) server. In this project I included a small Python MCP server (mcp-python-server.py), which uses FastMCP to expose Python functions through the MCP, so that the complete integration can be tested locally.

If you want to learn more about MCP servers, check my latest article: Model Context Protocol (MCP) with InterSystems IRIS - From Zero to Hero

For example, the MCP server can expose simple arithmetic operations:

from fastmcp import FastMCP

mcp = FastMCP("HealthcareStatistics")

@mcp.tool()

def add_numbers(first: float, second: float) -> float:

"""Add two numbers."""

return first + second

On the IRIS side, because this example uses an MCP server over stdio, the Python script must be available in the same runtime environment as IRIS. In this project, mcp-python-server.py is copied into the IRIS container, where AI Hub can start it using the IRIS Python interpreter. We then connect that MCP server to AI Hub through a %AI.ToolSet.

/// External Python MCP toolset for deterministic arithmetic and synthetic-data statistics..

Class Test.ToolSet.StatisticsMCP Extends %AI.ToolSet

{

XData Definition [ MimeType=application/xml ]

{

<ToolSet Name="StatisticsMCP">

<Description>Deterministic arithmetic and synthetic-data statistics from an external Python MCP process.</Description>

<MCP Name="Statistics">

<Stdio Executable="/usr/irissys/bin/irispython"

Args="/home/irisowner/dev/mcp-python-server.py"/>

</MCP>

</ToolSet>

}

}

This is still a normal %AI.ToolSet, so it can be attached to an agent in the same way as the other ToolSets. The only difference is that, instead of declaring a native ObjectScript tool implementation, the ToolSet contains an <MCP> definition, while Executable points to the Python interpreter available inside the IRIS container and Args identifies the Python MCP server that should be executed.

Starting a conversation: sessions, monitoring and chat

Once the agent has been configured, the next step is to actually use it. AI Hub separates the definition of an agent from an individual conversation with that agent.

First we create and initialize the agent:

Set agent = ##class(Test.Agent).%New()

Set sc = agent.%Init()

The Test.Agent class tells AI Hub which model, instructions, skills and ToolSets are available. It does not, however, represent a particular conversation. For that we create a session:

Set session = agent.CreateSession()

A session represents the state of one conversation with the agent. This distinction becomes important as soon as we want to send more than one message.

For example:

Set response = agent.Run(session,"Find the diabetic patients",10)

Do ##class(%AI.System).RenderMarkdown(response)

Run() starts the agent loop for the supplied prompt. AI Hub sends the conversation and available capabilities to the model and processes the response. If the model requests a tool, AI Hub executes it, adds the tool result to the conversation and lets the model continue.

The result is therefore not necessarily produced by a single LLM request:

User prompt





LLM



├── requests a tool

│ │

│ ▼

│ AI Hub

│ │

│ ▼

│ tool executes

│ │

◄─────────┘





LLM





Final response

This loop is one of the reasons for passing a maximum number of iterations to Run(). In the example above, 10 gives the agent enough room to make intermediate tool calls without allowing the execution loop to continue indefinitely.

Continuing the same conversation

The session becomes particularly useful when we call the agent again.

Set response = agent.Run(session,"Now summarize their main characteristics",10)

Because we pass the same session, this is a continuation of the previous conversation. The model can work with the context already accumulated in that session rather than treating the second prompt as an unrelated request.

If instead we create another session:

Set anotherSession = agent.CreateSession()

we have started a new conversation, even though both sessions use the same Test.Agent.

The distinction can therefore be summarized as:

Test.Agent



├── Session A

│ ├── User message

│ ├── Tool call

│ ├── Tool result

│ └── Assistant response



└── Session B

└── Independent conversation

So, we can say that the agent defines the capabilities; the session holds the conversation using those capabilities.

Seeing what the agent is doing with a monitor

When developing an agent, looking only at the final response tells us only part of the story. We also want to understand how the agent arrived at that response: how many iterations were required, whether tools were called, and how much work was performed before the final answer was produced.

In this project I defined a specific monitor class:

Class Test.DemoMonitor Extends %RegisteredObject

{

Method OnIterationStart(iteration As %Integer, maxIterations As %Integer, session As %AI.Agent.Session)

{

Write !,"[agent iteration ",iteration,"/",maxIterations,"]",!

}

Method OnIterationComplete(iteration As %Integer, response As %AI.LLM.Response, session As %AI.Agent.Session)
{
Set stats=session.GetStats()
Set totalToolCalls=stats.%Get("total_tool_calls",0)
Set totalToolDuration=stats.%Get("total_tool_duration_ms",0)
Write "Token usage: ",response.Usage.%ToJSON(),!
Write "Total tool calls: ",totalToolCalls," | Total tool duration (ms): ",totalToolDuration,!
}

}

This use it we can create a monitor and pass it to Run():

Set monitor = ##class(%AI.Agent.Monitor).%New()

Set response = agent.Run(session,"Find diabetic patients and summarize the cohort",10,monitor)

The monitor lets us observe the execution of the agent loop instead of seeing only the final response, for example:

[agent iteration 1/5] 

Token usage: {"completion_tokens":81,"prompt_tokens":1267}

Total tool calls: 2

Monitoring the execution helps us distinguish between these cases.

Inspecting the session statistics

The monitor shows what is happening during execution. AI Hub also gives us a second view through the statistics stored on the session:

Write "Parent stats: ",session.GetStats().%ToJSON(),!

Because the same session is reused for several prompts, GetStats() gives us the accumulated statistics for that conversation rather than only information about the last prompt.

Delegating work to sub-agents

So far our agent has handled a request by interacting with the model and calling tools. AI Hub also allows an agent to delegate part of its work to another agent.

This is useful when a task benefits from a separate role, set of instructions, or context. Instead of asking the main agent to perform every step itself, it can create a sub-agent, give it a specific task, wait for its response, and then continue its own execution.

In this project I use two forms of delegation:

  • Predefined sub-agents, where the specialist role and instructions are defined in advance.
  • Generic delegation, where the parent creates a specialist dynamically for a task.

In both cases, I use the same pattern as for the rest of the project: delegation is exposed as a tool (agent as a tool pattern). The parent agent sees a normal tool call, while the implementation behind that tool creates and runs a %AI.Agent.SubAgent.

Defining a predefined sub-agent

A predefined sub-agent is useful when we already know that part of our workflow should be handled by a specialist. For example, we may want a reviewer, validator, summarizer, or domain-specific analyst whose role and instructions are controlled by the application rather than generated dynamically by the parent agent.

A simplified delegation tool looks like this:

Class MyApp.SubAgents.Reviewer Extends %AI.Tool

{

Parameter DESCRIPTION = "Delegate content to a specialized child agent for review.";

Property ParentAgent As %AI.Agent;

Method %OnNew(parentAgent As %AI.Agent = "") As %Status [ Private ]
{
If $ISOBJECT(parentAgent) {
Set ..ParentAgent=parentAgent
}
Quit $$$OK
}

/// Review a task from the parent agent.

///

/// Args:

/// task: task to be reviwed

Method Review(task As %String) As %String [ WebMethod ]

{

Set output = ""

Try {

If '$ISOBJECT(..ParentAgent) {

$$$ThrowStatus($$$ERROR($$$AICoreIncompleteInitialization,$CLASSNAME(),"ParentAgent"))

}

Write "["..%ClassName()"] called as tool",!

Set prompt = "You are a specialist reviewer. Review only the supplied content and provide a concise assessment."

Set subagent=##class(%AI.Agent.SubAgent).Create(..ParentAgent,prompt,"")

Set subagent.ToolManager=##class(%AI.ToolMgr).%New()

Set session=subagent.CreateSession()

Set response=subagent.Run(session,task)

Set output = response.Content

} Catch exception {

Set sc = exception.AsStatus()

Set output = "Review failed: "_$SYSTEM.Status.GetErrorText(sc)

}

Return output

}

}

From AI Hub's point of view, Review() is still simply a tool operation because it is exposed as a [ WebMethod ].

The child agent must be created in the context of the parent agent. For this reason, the delegation tool keeps a reference to its parent:

Property ParentAgent As %AI.Agent;

Generic Delegation

Predefined delegation works well when we already know which specialist we want to call. In other cases, however, the required specialist depends on the current task.

For this reason, the project also includes a generic delegation tool (that can be found in the EAP GitHub as well). The parent agent does not call a fixed reviewer or validator. Instead, it asks the delegation tool to create a child agent for a role described at runtime:

/// Delegate Task Tool - Demonstrates Recursive Language Model (RLM) pattern

/// This tool creates a sub-agent to handle a delegated task

Class Test.Tools.DelegateTasks Extends %AI.Tool

{

/// Tool description for LLM
Parameter DESCRIPTION = "Delegate a task to a specialized sub-agent. Use this when the current task has distinct subtasks that would benefit from focused attention.";

/// Parent agent reference (set by the agent using this tool)
Property ParentAgent As %AI.Agent;

/// Create the delegation tool already bound to its parent agent.
Method %OnNew(parentAgent As %AI.Agent = "") As %Status [ Private ]
{
If $ISOBJECT(parentAgent) {
Set ..ParentAgent = parentAgent
}
Quit $$$OK
}

/// Delegate a task to a specialized sub-agent
///
/// Use this when the current task has distinct subtasks that would benefit from focused attention.
/// The sub-agent will have its own conversation context and can use the same tools as the parent.
///
/// Parameters:
/// - task: The task to delegate to the sub-agent (required)
/// - specialistRole: The role/expertise of the sub-agent (e.g., "code reviewer", "writer", "data analyst")
/// - context: Additional context to pass to the sub-agent
Method Execute(task As %String, specialistRole As %String = "", context As %String = "") As %String [ WebMethod ]
{
Try {
If '$ISOBJECT(..ParentAgent) {
$$$ThrowStatus($$$ERROR($$$AICoreIncompleteInitialization, $CLASSNAME(), "ParentAgent"))
}

    Write !, "[Delegation] Creating sub-agent with role '"_specialistRole_"' for task: ", $EXTRACT(task, 1, 50), "...", !

    // Build system prompt for sub-agent
    Set systemPrompt = "You are a helpful assistant"
    If specialistRole '= "" {
        Set systemPrompt = "You are a " _ specialistRole _ " assistant"
    }
    If context '= "" {
        Set systemPrompt = systemPrompt _ ". Context: " _ context
    }

    Write "[Delegation] Sub-agent role: ", systemPrompt, !

    // Create sub-agent using CreateSubAgent API
    // This will use safe_block_on internally to handle nested runtime contexts
    Set subagent = ##class(%AI.Agent.SubAgent).Create(
        ..ParentAgent,
        systemPrompt,
        "" // No additional config for now
    )

    Write "[Delegation] Sub-agent created, executing task...", !

    // Run the sub-agent on the delegated task
    Set session = subagent.CreateSession()
    Set response = subagent.Run(session, task)

    Write "[Delegation] Sub-agent completed", !
    Write "[Delegation] Response length: ", $LENGTH(response.Content), " characters", !

    Return response.Content

} Catch ex {
    Return "Error in delegation: " _ ex.DisplayString()
}

}

}

From the parent model's perspective, Execute() is simply another tool. The interesting part is that specialistRole is not fixed when the application is written.

For example, the parent could request:

specialistRole = "mental-math teacher"

task = "Explain why multiplication by zero always returns zero."

In another conversation, the same tool could be called with:

specialistRole = "technical editor"

task = "Review this explanation for clarity."

Both calls use the same delegation implementation, but they create child agents with different responsibilities.

Registering a generic delegation tool without a Toolset

If we do not want to create a specific Toolset containing sub-agents or delegation tool, we can register a single generic tool directly when the parent agent is initialized:

Method %OnInit() As %Status

{

......

Do things

......

Set sc=..RegisterGenericDelegateTool()

If $$$ISERR(sc) Quit sc

Quit $$$OK

}

Method RegisterGenericDelegateTool()

{

Set delegateTool=##class(Test.Tools.DelegateTasks).%New($THIS)

Do ..ToolManager.AddTool(delegateTool)

}


Start the environment

Now that we have covered the main building blocks, it is time to run the project and see them working together.

A first conversation in ObjectScript

Before opening the browser, it is useful to run one conversation directly. This makes the distinction between the agent and its session explicit. 

Open IRIS terminal and create a bundled simple chat agent:

Set agent=##class(Test.Agents.SimpleAgent).%New()

Set sc=agent.%Init()

Do $SYSTEM.Status.DisplayError(sc)

%Init() prepares the agent configuration and, in this project, also registers the generic and predefined delegation tools with the current agent instance.

Set session=agent.CreateSession()

Set maxIterations=5

Set monitor=##class(Test.DemoMonitor).%New()

CreateSession() creates the conversation context.

We can run the first request:

Set prompt="I have three tasks to finish today. Can you help me decide which one to do first?"

Set response=agent.Run(session,prompt,maxIterations,monitor)

Do ##class(%AI.System).RenderMarkdown(response.Content)

Run() submits a prompt, with an iteration limit and the demo monitor. 

The returned response exposes its text through Content.

Keep the same session for a follow-up:

Set prompt="One task has a deadline today; the other two can wait until Friday."

Set response=agent.Run(session,prompt,maxIterations,monitor)

Do ##class(%AI.System).RenderMarkdown(response.Content)

You can also register a skill from the terminal:

Set sc=agent.UseSkill("Test.Skill.Caveman")

Set prompt="Load the Caveman skill and explain your previous answer briefly."

Set response=agent.Run(session,prompt,maxIterations,monitor)

Write session.ActiveSkills.%ToJSON(),!

UseSkill() makes the skill available to the agent programmatically, while session.ActiveSkills lets us inspect which skills are active in the current conversation. This is useful when we want the application, rather than the model, to decide that a particular behavior should be applied.

We can inspect the tools exposed to the model at any time:
Write agent.ToolManager.%Discover().%ToJSON(),!

The next step is to use delegation. Suppose we want a sub-agent to review the previous response:

Set prompt="Call Execute to delegate review of this result to a concise technical reviewer. Use specialistRole='technical reviewer'. Task: "_response.Content

Set response=agent.Run(session,prompt,maxIterations,monitor)

Do ##class(%AI.System).RenderMarkdown(response.Content)

After these interactions, we can inspect the sub-agents created by the parent:

Write "Spawned sub-agents: ",agent.SubAgents.Count(),!

For i=1:1:agent.SubAgents.Count() {

Write !,agent.SubAgents.GetAt(i).SystemPrompt,!

}

This is particularly useful with generic delegation because the system prompt shows the role that was assigned to each dynamically created specialist.

Testing agents in the browser

Once the terminal flow is clear, the browser UI gives us a more convenient way to repeat the same experiments interactively.

The important point is that the browser does not define another kind of agent. The agent classes still live in ObjectScript, run inside IRIS, and use the same tools, skills, sessions, and delegation logic described earlier.

Opening the Agent Studio

Run the docker compose and open UI at http://localhost:5174

The initial workspace is divided into two main areas:

  • Sidebar
    • Recent chats
    • Configuration
    • Selected Agent
  • Main chat
1_homepage.png

Selecting an existing %AI.Agent

The agent selector is populated from the agent classes installed in IRIS. The frontend receives from the backend the concrete %AI.Agent subclasses and their metadata such as description, example prompt, ToolSets and skills.

Choosing the model

The model selector follows a similar approach. Instead of maintaining a predefined list of model names, the UI queries the configured Ollama instance and presents the models that are currently installed.

Model selector populated with models from the configured Ollama instance 

The agent logic remains unchanged while only the model performing the reasoning changes.

Starting a conversation

Once the agent and model are selected, we can send a prompt exactly as we did from the terminal. 

The browser sends the request to IRIS, where the selected agent is instantiated and the normal AI Hub execution loop runs, and finally it displays the result of the same agent execution we could inspect from ObjectScript.

IRISAgent response listing globals and estimated sizes

Loading a skill from the UI

Skills can also be controlled directly from the browser. New conversations start without active skills. Selecting a skill and clicking Load sends an explicit request to IRIS to activate that skill for the conversation.

Caveman loaded with an immediate Studio confirmation

The UI then marks the skill as active and changes the available action to Unload.

An example of a skill usage

After the skill has been loaded, we can use it:

SimpleAgent answering a calculation prompt with Poet active

Reopening conversations

The UI stores conversations and their AI Hub session state in IRIS. The Recent chats panel lets us reopen a previous conversation instead of starting over.


Conclusion

The goal of this project is to make the different pieces of AI Hub easier to explore together rather than as isolated features.

Starting from a native %AI.Agent class, we can configure a model provider, expose ObjectScript and MCP tools, add reusable skills, maintain conversations through sessions, inspect execution statistics, and delegate work to specialized sub-agents.

The project is intentionally a playground rather than a production architecture. The AI Hub APIs used here are part of the Early Access preview and may evolve, but the examples provide a practical starting point for experimenting with the SDK and understanding how its main building blocks relate to one another.

If you are exploring AI Hub, clone the repository, try the bundled agents, inspect their available tools, change a skill, experiment with delegation, and then add your own %AI.Agent class. That is where the project becomes most useful: not as a finished application, but as a small environment for learning what you can build next.

Top comments (0)