Agent Manager

What is it?

A platform to create, run, and interact with agents.

You build in a drag-and-drop canvas, wiring agents and tools together into different orchestration patterns — workflow, hub-and-spoke, or swarm — using bidirectional connections.

Workflow

Steps run in sequence — each agent's output feeds the next.

Hub-and-spoke

A central agent routes work out to specialists and back.

Swarm

Agents talk to each other directly — no central router.

Four layers of abstraction

What you build stacks up through four layers, each one a grouping of the layer below it:

Tools
Python functions. Either assigned to a utility agent, or dropped on the canvas on their own as a static workflow node.
Utility agent
A ReActReAct = Reason + Act. The agent reasons about its input, then decides whether to act (call a tool) before producing an output. agent with a system prompt. It takes an input, reasons about it, decides whether to use a tool (if it has one assigned), and returns an output.
Super agent
An abstraction — simply a grouping of utility agents and/or tools.
Orchestrator agent
An abstraction — a grouping of super agents, utility agents, tools, or other orchestrator agents. Context is shared across everything inside it.

Visually, the layers nest. An orchestrator agent groups super agents, utility agents, and tools (and even other orchestrator agents); expand any super agent and you'll find the same building blocks inside — utility agents that each may own one or more tools:

Orchestrator Agent Super Agent Orchestrator Agent Super Agent Utility Agent Tool

Technical FYI

Behind the scenes, the agentic orchestration is built on the Microsoft Agent FrameworkOne of the many pythonic, agentic frameworks on the market. Similar to LangGraph, CrewAI, Pydantic, etc., with the Agent Manager product team's own plethora of custom classes and integrations to make it work the way it does in the drag-and-drop canvas.

Conversational UI (CUI)

What is it?

A conversational chat interface — like ChatGPT or Claude.

Out of the box it's just chat; it isn't agenticAble to take actions to complete a task — reason and use tools — not just reply with text.. It becomes agentic by your design — you decide what it can do.

You do that by building a PersonaIn CUI, a Persona is an agent — a chat personality you configure with a system prompt and, optionally, tools.. A Persona is another word for agent.

Two things turn it from a plain chatbot into one that performs tasks and integrates with systems:

Persona  =  Agent

System prompt

Tells the Persona who it is and how to behave.

Python tools

Functions you write and add to the Persona so it can act, not just talk.

How it works

  1. Create a Persona.
  2. Give it a system prompt.
  3. Write Python tools and assign them to the Persona.
  4. Chat with it. The Persona carries out what you ask — following its system prompt and using the tools you gave it.

Integration

Which do I use?

Agent Manager and CUI are two separate applications. Each runs on its own — you might use one, the other, or both, depending on the use case.

Common misconception: Most users assume the two are one product that integrates itself at setup. It doesn't — connecting them is done manually by the developer, per use case. There's a bit of a learning curve with integrating the two, but it gets easier with experience.

Here's my rule of thumb for deciding. It's not prescriptive — just a general guideline from experience:

  • Do you need a conversational (chat) experience?
    • No
      AM onlyBuild on Agent Manager
    • Yes · build on CUI
      Is it a long-running task or flow?
      • No
        CUI onlyOnly use CUI
      • Yes
        CUI + AMIntegrate CUI with Agent Manager

How does integration work?

There's no one-click button or setup page that wires them together. You connect them with API calls inside your Python tools. The pattern looks like this:

  • A Persona in CUI has a tool that triggers a Super Agent or Orchestrator Agent in Agent Manager.
  • That agent runs the tasks it's built to do. Along the way, an agent (or a standalone tool) calls CUI's API to push a message back into the same conversation thread you triggered it from.
  • Agent Manager can pause its run and wait for a reply.
  • A tool in CUI calls Agent Manager's API to send a response back into that run — its Huddle TraceAgent Manager's term for a single agent execution (run). — which then picks up where it left off.

So the communication is entirely API-based. Where and when you place these checkpoints is up to your design and flow.

Here's one example of that back-and-forth — a user asking for a security scan, then for fixes:

User-Facing Conversational UI
Behind the scenes Agent Manager
1
User

"Hi, please perform a security scan on this code for me."

API call →
2
Agent Manager

Super Agent triggered.

3
Super Agent

Doing tasks… scanning…

← API call
4
Conversational UI

"The agent is now scanning the code…"

5
User

"Thanks for the findings. Please action the fixes…"

API call →
6
Super Agent

Applying fixes…

What's the implication of this?

There's no shared context between the LLMs in CUI and the LLMs in Agent Manager. Whatever the agents do in Agent Manager, the agents in CUI don't know about — and vice versa. They work in isolation. The two only ever meet at the communication step, where a small chunk of context is passed across in the API call.

CUI Context Window Agent Manager Context Window Messages can be emitted to CUI during AM execution

Only the dashed chunks ever cross. Purple is the context handed over when CUI triggers Agent Manager and when it returns; orange is messages Agent Manager emits back to CUI while it's still running. Everything else in each window stays private.

Utility Agents

A utility agent is a ReActReAct = Reason + Act. The agent reasons about its input, then decides whether to act (call a tool) before producing an output. agent — an LLM node that takes an input, reasons about it, and decides whether to use a tool before answering. You configure it with a system prompt (how it should behave) and, optionally, one or more tools it can call.

Example: a User Story Creator

Let's build one. Create a utility agent and fill in the fields below — use the copy button on each to paste into the interface in Agent Manager. Leave Tags and Tools alone for now.

Name
User Story Creator
Description
Writes a user story from a feature request.
Tags (optional)
Leave empty
Tools (ignore for now)
Ignore for now
Detail (system prompt)
You write clear, concise user stories in the format: "As a [user], I want [goal], so that [benefit]."

Save the utility agent, then move on to the next section to create a super agent that contains this User Story Creator — so you can see it in action.

Super Agents

To test a utility agent, you put it inside a super agent and run that.

Create a new super agent, search for your User Story Creator in the Utility Agents panel, then drag and drop it onto the Flow canvas. Connect the Start and End nodes to it.

Name
User Story Super Agent
Description
Runs the User Story Creator utility agent.
Max Messages
Leave as is (30)
Tags (optional)
Leave empty
Utility Agents Search bar Flow Start User Story Creator End

Save the super agent, then go back to the main Super Agents page. Search for the super agent you created and click the play button.

Enter your input — for example, "Create me a user story about a personal banking application" — then click Run.

Super Agents
User Story Super Agent

After clicking Run, you're taken to the Huddle TraceAgent Manager's term for a single agent execution (run). page for the execution, where you can monitor the activity of the agent(s). Move on to the next section to learn more about Huddle Traces.

Huddle Traces

This is where you monitor the granular activity of a super or orchestrator agent — the inputs, tool uses, and outputs of every agent and standalone tool that runs during an execution.

Trace Overview Super Agent User Story Super Agent Utility Agent User Story Creator Tool fetch_org_docs 1m 2s 12s 35s 15s

Once the agent starts running, click the individual nodes in the swimlane view to see what's happening.

Tool executions show the final return of the Python function as Result, plus Logs — anything you emit from the function with print statements.

For example, clicking the fetch_org_docs node from the trace above reveals its details:

Selected Trace Details
Start Time · 14:32:06
End Time · 14:32:21
Duration · 15s
Output
Result
ACME Inc. uses the agile methodology for capturing product requirements from the business — breaking them into epics and user stories, managed in Jira and refined each sprint…
Logs
Fetching document from Confluence… 200 Success. Retrieved 18 of 20 sections (≈4,200 tokens).
Input
{ "url": "https://acme.inc.docs/agile", "limit": 20 }

Tools

Tools are Python functions an agent (or a standalone canvas node) can call to actually do something — fetch data, hit an API, run logic. You give the tool a name, describe its parameters, and write the function; the agent decides when to run it.

Here's the example fetch_org_docs tool from the trace in the previous section, which is a dummy function that returns a static string:

Name
fetch_org_docs
Description
Function to fetch ACME org's docs
Tags (optional)
Leave empty
Function Parameters
+ Add Parameter
Parameter #1
limit
int
No. of docs to retrieve
Source Code
import core

def fetch_org_docs(limit: int) -> str:
    """Fetch up to `limit` org docs from the knowledge base."""

    print(f"Fetching document from Confluence with limit={limit}…")

    doc = (
        "ACME Inc. uses the agile methodology for capturing product requirements from"
        "the business — breaking them into epics and user stories, managed in Jira "
        "and refined each sprint. All User Stories have `Made by ACME Inc` at the end."
    )

    return doc

core.output(fetch_org_docs(**core.args()))

Two lines are required in every tool:

  1. import core at the top of the tool.
  2. core.output(<FUNCTION_NAME>(**core.args())) on the last line of the tool — that's what feeds the agent's arguments into your function and returns the result.

Next, save the tool, then go back to the User Story Creator utility agent and add the fetch_org_docs tool to it.

Replace the Utility Agent's Detail (system prompt) with this:

You are a User Story creation Agent.
First, always use the fetch_org_docs tool to get the org's documents.
Always use 1 for the limit parameter of the tool.
You write clear, concise user stories in the format: "As a [user], I want [goal], so that [benefit]."

You can now re-run the super agent and monitor the Huddle Trace to see the utility agent use the tool!

Orchestrator Agents

An orchestrator agentAn abstraction — a grouping of super agents, utility agents, tools, or other orchestrator agents. Context is shared across everything inside it. is just one level of abstraction above a super agent. It lets you group everything you've learned so far — toolsPython functions. Either assigned to a utility agent, or dropped on the canvas on their own as a static workflow node., utility agentsA ReAct agent with a system prompt. It takes an input, reasons about it, decides whether to use a tool (if it has one assigned), and returns an output., super agentsAn abstraction — a grouping of utility agents and/or tools., and even other orchestrator agentsAn abstraction — a grouping of super agents, utility agents, tools, or other orchestrator agents. Context is shared across everything inside it. — all within one agent.

Show diagram
Orchestrator Agent Super Agent Orchestrator Agent Super Agent Utility Agent Tool

So far, we've made a User Story super agent with a sample tool.

Now, your first exercise is to create a super agent that creates test plans.

Exercise: Create a Super Agent which creates Test Plans.
Hint

Start by creating a utility agent, then add it to a new super agent. Exactly like we did in the Utility and Super Agent sections.

Answer
Name
Test Plan Agent
Description
Utility Agent which creates Test Plans
Detail (system prompt)
You are a Test Plan Agent.
Your test plans always include the following sections:

Story Under Test — restate the user story clearly.
Acceptance Criteria — list the conditions that must be true for the story to be considered done.
Test Cases — for each acceptance criterion, write at least one test case with: Test ID, Description, Preconditions, Steps, and Expected Result.
Edge Cases & Negative Tests — identify and describe scenarios where things could go wrong (invalid input, network failure, empty states, permission issues, etc.).
Non-Functional Considerations — note any performance, security, or accessibility checks relevant to the story.

Before we create an orchestrator agent, we need to create one more utility agent — the one that will act as our orchestrator.

Name
Business Orchestrator Agent
Description
Orchestrates decision making based on user input
Detail (system prompt)
You are an Orchestrator Agent.
Your sole responsibility is to route tasks to the appropriate agents and relay their outputs — nothing more.

Rules you must never break:
- Never analyse, interpret, summarise, truncate, paraphrase, or add to any agent's output.
- Never attempt to fulfil the user's request yourself.
- Always delegate every part of the request to the appropriate agent(s).
- When all agents have completed their work, hand off to END with the full, unmodified outputs from every agent, concatenated in the order they were received. Each agent's output must be preceded by a section heading that describes the content (e.g. ## User Stories, ## Test Plan). Do not add any other text, introductory or closing remarks, or omit a single line.

Your only permitted actions are:
- Read the user's input and determine which agent(s) to invoke.
- Pass the relevant input to each agent.
- Collect each agent's complete output exactly as returned.
- Hand off to END with all outputs joined together, verbatim and in full, each preceded by its section heading.

If you find yourself writing anything that was not produced by an agent — other than the section headings — stop. You are out of scope.

Now we can assemble everything we've made into one orchestrator agent. Navigate to the Orchestrator page and assemble it like this:

Name
Business Orchestrator
Description
Routes user requests to the right agents and returns their outputs.
Max Messages
Leave as is (30)
Tags (optional)
Leave empty
Super Agents Utility Agents Search bar Flow Start User Story Super Agent Business Orchestrator Agent Test Plan Super Agent End

Notice the bidirectional connections between the central utility agent and the adjacent super agents.

When done, save the agent and run it with this prompt:

Show prompt
ACME Inc. is modernising its personal banking mobile application to improve customer retention and reduce call centre volume. The first initiative focuses on account visibility — customers should be able to view their current balances across all linked accounts and browse recent transactions without needing to contact support. The app must support both current and savings accounts, display at least 90 days of transaction history, and clearly indicate pending versus settled transactions. The solution must comply with ACME's internal data privacy standards and be accessible to users with visual impairments.

Help me create 2 user stories and test plan.

Following the Huddle Trace, you'll see the trace-wise flow and activity of the agents — especially the orchestrator. You can also click the blue super agent nodes in the swimlane diagram to view their individual nested traces.

CUI Integration

What do we have so far?

So far we've learned how to trigger Super and Orchestrator Agents directly in Agent Manager. We also heard about CUI being the user-facing interface for chat-based use cases. So how do we integrate it with agents? Recall the Integration page, where we connect the two with API calls inside Python functions.

Trigger Agent from CUI

We need a PersonaIn CUI, a Persona is an agent — a chat personality you configure with a system prompt and, optionally, tools. plus a tool in CUI that triggers our Business Orchestrator Agent with a text input.

Create a tool in CUI as follows — use the copy button on each field to paste into the interface:

Name
business_orch_agent_tool
Type
Tool
Description
Triggers the Business Orch Agent in AM
Parameters
Name
USER_INPUT
Type
String
Required
Description
The user's input, verbatim, concatenated and structured but not summarized.
Python Script
import asyncio
import os
from uuid import UUID
import httpx

# ------------ CUI CREDENTIALS ------------
USERNAME = config_service.get("tool-cui-user","")
PASSWORD = config_service.get("tool-cui-password","")
BASE_URL = config_service.get("tool-cui-base-url","")

# ----------- CUI RELATED DATA ------------
ATTACHMENTS = user_query_context.attachments
CONVERSATION_ID = user_query_context.conversation_id  # ID of the conversation
JWT_TOKEN = user_query_context.user_specific_jwt_token # JWT token for authentication

# ------------ AM CREDENTIALS ------------
API_URL = config_service.get("agentmanager-api-url","")
API_KEY = config_service.get("agentmanager-api-key","")

# ------------ AM AGENT ID AND TYPE ------------
RUN_AGENT_TEAM_ID = "c95edc15-ad49-4695-a006-a5113377ac90"
AGENT_TYPE = "ORCHESTRATOR" # Super Agent = "TEAM", Orchestrator Agent = "ORCHESTRATOR"
# EDIT THESE ^

# ------------ INPUT SENT TO AGENT MANAGER ------------
TASK = {
    "USER_INPUT": USER_INPUT,
    "cui_conversation_id": CONVERSATION_ID,
    "user_id": user_query_context.username,
    "recipient_id": user_query_context.user_id,
    "network_id": user_query_context.external_network_run_id
}

def normalize_filename(filename):
    name, ext = os.path.splitext(filename)
    inner_name, inner_ext = os.path.splitext(name)
    if inner_ext:
        name = f"{inner_name}_{inner_ext.lstrip('.')}"
    name = name.replace(".", "_").replace(" ", "_")
    suffix = os.urandom(2).hex()
    return f"{name}_{suffix}{ext}"

async def get_attachment_content(attachment_id):
	from app.dependencies import get_attachments_service
	from app.utils.dependencies import inject
	from uuid import UUID

	print(f"Start getting content of Attachment id: {attachment_id}")
	attachments_service = await inject(get_attachments_service)
	(attachment_info, data) = await attachments_service.get_attachment(
		file_id=UUID(attachment_id), user_id=user_query_context.username
	)
	print(f"#############Attachment info: {attachment_info}")
	return data

async def get_presigned_url(filename):
    async with httpx.AsyncClient(timeout=30) as client:
        payload = {
            "query": """mutation SetUploadedArtifact($uploadedArtifact: UploadedArtifactInput!, $setUploadedArtifactId: String) {
                        setUploadedArtifact(
                            uploadedArtifact: $uploadedArtifact
                            id: $setUploadedArtifactId
                        ) {
                            uploadedArtifact {
                                id
                                name
                                description
                                createdBy
                                createdOn
                                updatedBy
                                updatedOn
                                s3Key
                                tags
                                __typename
                            }
                            message
                            presignedUrl
                            __typename
                        }
                    }""",
            "variables": {
                "uploadedArtifact": {
                    "name": filename,
                    "description": "User Input File"
                }
            },
        }
        headers = {"Content-Type": "application/json", "x-api-key": API_KEY}

        try:
            response = await client.post(API_URL, json=payload, headers=headers)
            print(
                f"Presigned URL response status code: {response.status_code}")
            print(f"Presigned URL response content: {response.text}")

            response.raise_for_status()
            data = response.json()

            # Enhanced error handling and debugging
            if "errors" in data:
                print(f"GraphQL errors: {data['errors']}")
                raise ValueError(f"GraphQL errors: {data['errors']}")

            # Check if the response has the expected structure
            if "data" not in data:
                print(f"Missing 'data' field in response: {data}")
                raise ValueError(f"Unexpected response structure: {data}")

            if data["data"] is None:
                print(f"GraphQL returned null data: {data}")
                raise ValueError(f"GraphQL returned null data: {data}")

            if "setUploadedArtifact" not in data["data"]:
                print(f"Missing setUploadedArtifact in response: {data}")
                raise ValueError(
                    f"Missing setUploadedArtifact in response: {data}")

            set_uploaded_artifact = data["data"]["setUploadedArtifact"]
            if set_uploaded_artifact is None:
                print(f"setUploadedArtifact is null: {data}")
                raise ValueError(f"setUploadedArtifact is null")

            if "presignedUrl" not in set_uploaded_artifact:
                print(
                    f"Missing presignedUrl in setUploadedArtifact: {set_uploaded_artifact}")
                raise ValueError(f"Missing presignedUrl in response")

            presigned_url = set_uploaded_artifact["presignedUrl"]
            if presigned_url is None:
                print(f"presignedUrl is null: {set_uploaded_artifact}")
                raise ValueError(f"presignedUrl is null")

            return presigned_url

        except httpx.HTTPStatusError as e:
            print(f"HTTP error getting presigned URL: {e}")
            print(f"Response content: {e.response.text}")
            raise
        except Exception as e:
            print(f"Error getting presigned URL: {e}")
            raise

async def upload_file(presigned_url, file_content, content_type):
    if not presigned_url:
        raise ValueError("presigned_url is None or empty")

    async with httpx.AsyncClient(timeout=60) as client:
        headers = {"Content-Type": content_type}
        try:
            print(f"Uploading file to: {presigned_url}")
            print(f"File size: {len(file_content)} bytes")
            print(f"Content type: {content_type}")

            response = await client.put(
                presigned_url, headers=headers, content=file_content
            )
            print(f"Upload response status code: {response.status_code}")
            print(f"Upload response text: {response.text}")

            response.raise_for_status()
            return response.status_code

        except httpx.HTTPStatusError as e:
            print(f"HTTP error uploading file: {e}")
            print(f"Response content: {e.response.text}")
            raise
        except Exception as e:
            print(f"Error uploading file: {e}")
            raise

async def trigger_execution():
    async with httpx.AsyncClient(timeout=30) as client:
        payload = {
            "query": """mutation RunAgent($runAgentId: String!, $type: RunAgentType!, $task: String!) {
                runAgent(id: $runAgentId, type: $type, task: $task) {
                    message
                    traceId
                    __typename
                }
            }""",
            "variables": {"runAgentId": RUN_AGENT_TEAM_ID, "task": str(TASK), "type": AGENT_TYPE},
        }
        print(f"Payload for execution: {payload}")
        headers = {"Content-Type": "application/json", "x-api-key": API_KEY}
        try:
            response = await client.post(API_URL, json=payload, headers=headers)
            print(f"Execution trigger response status: {response.status_code}")
            print(f"Execution trigger response: {response.text}")
            response.raise_for_status()
            return response.json()
        except Exception as e:
            print(f"Error triggering execution: {e}")
            return {"message": "Failed to trigger execution", "error": str(e)}


async def main():
    presigned_url = None
    
    headers = {
        "accept": "application/json",
        "apiToken": JWT_TOKEN,
        "Content-Type": "application/json",
    }

    csv_counter = 1
    docx_counter = 1
    pdf_counter = 1
    zip_counter = 1
    pptx_counter = 1
    md_counter = 1
    json_counter = 1
    txt_counter = 1
    
    for attachment in ATTACHMENTS:
        if attachment.filename.endswith('.zip'):
            print(f"Processing code repository: {attachment.filename}")
            zipkey = f"zip{zip_counter}"
            TASK[zipkey] = attachment.filename
            print(f"Uploading file {zipkey} (zip)...")
            
            try:
                text_content = await get_attachment_content(attachment.id)
				# text_content = await get_attachment_content(attachment.id, headers=headers)
                
                print(f"Start retrieving PRESIGNED_URL using {attachment.filename}")
                presigned_url = await get_presigned_url(normalize_filename(attachment.filename))
                print(f"PRESIGNED_URL: {presigned_url}")
                
                upload_status = await upload_file(
                    presigned_url,
                    text_content,
                    "application/zip",
                )
                print(f"File upload status: {upload_status}")
                zip_counter += 1  # Increment counter after successful processing
            except Exception as e:
                print(f"Error processing zip file: {e}")
                raise

        elif attachment.filename.endswith('.csv'):
            print(f"Processing CSV file: {attachment.filename}")
            csv_key = f"csv{csv_counter}"
            TASK[csv_key] = attachment.filename
            print(f"Uploading {csv_key}...")
            try:
                text_content = await get_attachment_content(attachment.id)
				# text_content = await get_attachment_content(attachment.id, headers=headers)
                print(f"Start retrieving PRESIGNED_URL using {attachment.filename}")
                presigned_url = await get_presigned_url(normalize_filename(attachment.filename))
                print(f"PRESIGNED_URL: {presigned_url}")
                upload_status = await upload_file(
                    presigned_url,
                    text_content,
                    "text/csv",
                )
                print(f"File upload status: {upload_status}")
                csv_counter += 1  # Increment counter after successful processing
            except Exception as e:
                print(f"Error processing CSV file: {e}")
                raise

        elif attachment.filename.endswith('.docx'):
            print(f"Processing DOCX file: {attachment.filename}")
            docx_key = f"docx{docx_counter}"
            TASK[docx_key] = attachment.filename
            print(f"Uploading {docx_key}...")
            try:
                text_content = await get_attachment_content(attachment.id)
				# text_content = await get_attachment_content(attachment.id, headers=headers)
                print(f"Start retrieving PRESIGNED_URL using {attachment.filename}")
                presigned_url = await get_presigned_url(normalize_filename(attachment.filename))
                print(f"PRESIGNED_URL: {presigned_url}")
                upload_status = await upload_file(
                    presigned_url,
                    text_content,
                    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                )
                print(f"File upload status: {upload_status}")
                docx_counter += 1
            except Exception as e:
                print(f"Error processing DOCX file: {e}")
                raise

        elif attachment.filename.endswith('.pdf'):
            print(f"Processing PDF file: {attachment.filename}")
            pdf_key = f"pdf{pdf_counter}"
            TASK[pdf_key] = attachment.filename
            print(f"Uploading {pdf_key}...")
            try:
                text_content = await get_attachment_content(attachment.id)
				# text_content = await get_attachment_content(attachment.id, headers=headers)
                print(f"Start retrieving PRESIGNED_URL using {attachment.filename}")
                presigned_url = await get_presigned_url(normalize_filename(attachment.filename))
                print(f"PRESIGNED_URL: {presigned_url}")
                upload_status = await upload_file(
                    presigned_url,
                    text_content,
                    "application/pdf",
                )
                print(f"File upload status: {upload_status}")
                pdf_counter += 1
            except Exception as e:
                print(f"Error processing PDF file: {e}")
                raise

        elif attachment.filename.endswith('.pptx'):
            print(f"Processing PPTX file: {attachment.filename}")
            pptx_key = f"pptx{pptx_counter}"
            TASK[pptx_key] = attachment.filename
            print(f"Uploading {pptx_key}...")
            try:
                text_content = await get_attachment_content(attachment.id)
                print(f"Start retrieving PRESIGNED_URL using {attachment.filename}")
                presigned_url = await get_presigned_url(normalize_filename(attachment.filename))
                print(f"PRESIGNED_URL: {presigned_url}")
                upload_status = await upload_file(
                    presigned_url,
                    text_content,
                    "application/vnd.openxmlformats-officedocument.presentationml.presentation",
                )
                print(f"File upload status: {upload_status}")
                pptx_counter += 1
            except Exception as e:
                print(f"Error processing PPTX file: {e}")
                raise

        elif attachment.filename.endswith('.md'):
            print(f"Processing MD file: {attachment.filename}")
            md_key = f"md{md_counter}"
            TASK[md_key] = attachment.filename
            print(f"Uploading {md_key}...")
            try:
                text_content = await get_attachment_content(attachment.id)
                print(f"Start retrieving PRESIGNED_URL using {attachment.filename}")
                presigned_url = await get_presigned_url(normalize_filename(attachment.filename))
                print(f"PRESIGNED_URL: {presigned_url}")
                upload_status = await upload_file(
                    presigned_url,
                    text_content,
                    "text/markdown",
                )
                print(f"File upload status: {upload_status}")
                md_counter += 1
            except Exception as e:
                print(f"Error processing MD file: {e}")
                raise

        elif attachment.filename.endswith('.json'):
            print(f"Processing JSON file: {attachment.filename}")
            json_key = f"json{json_counter}"
            TASK[json_key] = attachment.filename
            print(f"Uploading {json_key}...")
            try:
                text_content = await get_attachment_content(attachment.id)
                print(f"Start retrieving PRESIGNED_URL using {attachment.filename}")
                presigned_url = await get_presigned_url(normalize_filename(attachment.filename))
                print(f"PRESIGNED_URL: {presigned_url}")
                upload_status = await upload_file(
                    presigned_url,
                    text_content,
                    "application/json",
                )
                print(f"File upload status: {upload_status}")
                json_counter += 1
            except Exception as e:
                print(f"Error processing JSON file: {e}")
                raise

        elif attachment.filename.endswith('.txt'):
            print(f"Processing TXT file: {attachment.filename}")
            txt_key = f"txt{txt_counter}"
            TASK[txt_key] = attachment.filename
            print(f"Uploading {txt_key}...")
            try:
                text_content = await get_attachment_content(attachment.id)
                print(f"Start retrieving PRESIGNED_URL using {attachment.filename}")
                presigned_url = await get_presigned_url(normalize_filename(attachment.filename))
                print(f"PRESIGNED_URL: {presigned_url}")
                upload_status = await upload_file(
                    presigned_url,
                    text_content,
                    "text/plain",
                )
                print(f"File upload status: {upload_status}")
                txt_counter += 1
            except Exception as e:
                print(f"Error processing TXT file: {e}")
                raise

    print("Triggering execution...")
    execution_response = await trigger_execution()
    return f"Execution response: {execution_response}"

try:
    result = await main()
    return str(result)
except Exception as e:
    return f"Exception occurred: {e}"

Before saving, modify line 21 — replace RUN_AGENT_TEAM_ID with the ID of the Orchestrator Agent you created earlier.

The script makes a single API call to Agent Manager's run endpoint, passing the user's input as the agent's input.

Save the tool. Then create a Persona, give it a system prompt that tells it to call business_orch_agent_tool with the user's request, and add this tool to it. Now when you chat with the Persona, it triggers the Business Orchestrator Agent in Agent Manager.

Send Message from AM to CUI

Store CUI Variables

To send messages from AM back to CUI, we first need to store, on the AM side, some of the variables sent from CUI to AM (cui_conversation_id, user_id, etc.).

Do this by creating a new tool and Utility Agent that store these variables at runtime, before anything else proceeds.

Show tool
Name
save_local_variable
Description
Saves a local variable by key.
Tags (optional)
Leave empty
Function Parameters
+ Add Parameter
Parameter #1
key
str
The variable key to save
Parameter #2
value
str
The value to save
Source Code
import typing
import core

def save_local_variable(key: typing.Annotated[str, "The variable name/key to save"] = None,
    value: typing.Annotated[str, "The value to save"] = None):
    """
    Saves a local variable by key.
    """
    if value is None:
        return "Error: 'value' must be provided."
    try:
        existing = core.get_local_value(key)
        if existing is not None:
            return f"Variable '{key}' already exists with value '{existing}'. Not overwritten."
    except Exception:
        pass
    core.set_local_value(key, value)
    return f"Saved: {key} = {value}"

core.output(save_local_variable(**core.args()))

Then create a Utility Agent that uses this tool, with the system prompt below:

Show utility agent
Name
Save Variables Agent
Description
Stores the variables passed from CUI as local variables.
Tags (optional)
Leave empty
Tools (add the tool above)
save_local_variable
Detail (system prompt)
Your role is to use the save_variables tool to SAVE local variables.
You can call this tool as many times as needed depending on your input, which will typically be a JSON object or JSON string.

If the input is a JSON object (or a JSON string that parses to an object):
Iterate over each key-value pair and save each one as its own local variable, where:
key = the JSON key
value = the JSON value, VERBATIM

When saving, check if any key is user_input or a recognizable variation of it (e.g. vsce_user_input, userInput, user_query, request, prompt, message).
If so, also save that value under the normalized key user_input, in addition to saving it under its original key.
If no such key exists at all, save an additional variable where:
key = user_input
value = the full JSON input, VERBATIM

If the input is plain text (not JSON):
Save a single variable where:
key = user_input
value = the input, VERBATIM

Then, handoff only the user_input to the next agent.

Then, connect this agent between the START node and the Business Orchestrator Agent.

Send Message to CUI

Next, we need a tool which will allow Utility Agents to send a message to CUI:

Show tool
Name
send_msg_to_cui
Description
Send a message to CUI on behalf of the agent.
Tags (optional)
Leave empty
Function Parameters
+ Add Parameter
Parameter #1
message
str
The message content to send to CUI.
Source Code
import requests
import core
import typing

def send_msg_to_cui(
    message: typing.Annotated[str, "The message content to send to CUI."] = None
):
    """Send a message to CUI on behalf of the agent.

    Args:
        message: The message content to send to CUI.

    Returns:
        The original message string in all cases.
    """

    try:
        # GET ATR CREDENTIALS
        credentials = core.get_secret(name="atr")
        url_base = credentials.get("url")
        # RETRIEVE STORED CUI IDS
        recipient_id = core.get_local_value("recipient_id")
        conversation_id = core.get_local_value("cui_conversation_id")

        url = f"{url_base}/atr-gateway/identity-management/api/v1/auth/short-token"
        headers = {"Content-Type": "application/json"}
        body = {"username": credentials.get("username"), "password": credentials.get("password")}
        response = requests.post(url, json=body, headers=headers)
        token = response.json()["token"]

        sys_response_id = ""
        headers = {"apiToken": token}
        url = f"{url_base}/atr-gateway/genai/messages"
        body = {
            "conversation_id": conversation_id,
            "content": message,
            "recipient_id": recipient_id,
            "push_message_metadata": {
                "system_name": "agent_manager",
                "system_awaiting_reply": False,
                "system_response_id": sys_response_id,
            },
        }

        response = requests.post(url, json=body, headers=headers)
        print(f"Sent message to CUI successfully. Response: {response.content}")
        return message

    except Exception as e:
        print(f"Error sending message to CUI: {e}")
        return message

core.output(send_msg_to_cui(**core.args()))

Now create a Utility Agent which will utilise this tool.

Show utility agent
Name
Send to CUI
Description
Relays the agent's results back to the user in CUI.
Tags (optional)
Leave empty
Tools (add the tool above)
send_msg_to_cui
Detail (system prompt)
Use the send_msg_to_cui tool to relay to the user what has been done and/or generated, formatted in Markdown. For generated items—user stories, plans, code, etc.—do not summarize; keep them verbatim, and only structure the overall message. Render any presigned URLs as Markdown links using [label](url) format, e.g. [Download report](https://example.com/report.pdf?X-Amz-Signature=abc123).

If the input is only a presigned URL (or a URL with no accompanying description of work), respond with a brief closing message and render the URL as a Markdown link, e.g. "The agent has completed its tasks. You can download the generated artifacts here: [Download artifacts](https://example.com/artifacts.zip?X-Amz-Signature=abc123)"; infer the link label from the filename in the URL where possible (e.g. report.pdf → [Download report.pdf](url)), otherwise use a generic label like "Download artifacts".

Now add and connect this agent to the Utility Agent inside BOTH the User Story Super Agent and Test Plan Super Agent. For example, the User Story Super Agent's flow becomes:

Flow Start User Story Creator Send to CUI End

Jira MCP

In this lab, we'll learn how to use the Jira MCPHosted collection of reusable Python tools for agents. in Agent Manager to push data to a Jira instance.

First we'll add Jira-related credentials into Agent Manager, then create a tool to interface with the MCP, create an agent which utilises it, then incorporate it into our existing agent from the previous lab.

Create Secret

First, let's create the Jira secret.

Do this by navigating to the Secrets ManagerLocated in the top right of Agent Manager, under the settings button dropdown. page and clicking + Add New Secret.

Then, fill in the fields below using the credentials from the provided Excel:

Name
group_1_jira_secret
Description (optional)
> optional
Type
Single Group
Template Type
Custom OpenAI Anthropic MCP
Key / Values
url
value
email
value
api_token
value

Create Jira MCP Tool

Now we need a tool to talk to the hosted Jira MCP client. The client has its own LLM ReActReAct = Reason + Act. The agent reasons about its input, then decides whether to act (call a tool) before producing an output. agent behind the scenes, so the tool just hands it a prompt describing what to do in Jira, and the MCP client (LLM) decides which MCP tools to call to accomplish the task.

For example, the Utility Agent in Agent Manager is instructed to push User Stories to Jira. It calls the Jira MCP Tool with a prompt for the MCP Client; the MCP Client (an LLM ReAct agent underneath) interprets the prompt, then decides which Jira tool(s) in the MCP Server to use.

Utility Agent ReAct Agent Tool Python Script MCP Client ReAct Agent MCP Server MCP Tools calls prompt queries/uses

The result then travels back up the stack: the MCP Tool succeeds, the MCP Client agent acknowledges it and returns — which becomes the Agent Manager tool's output. The Utility Agent receives that and generates a response (e.g. "User stories successfully pushed").

Show tool
Name
jira_mcp_client_tool
Description
Calls the hosted Jira MCP client with a prompt.
Tags (optional)
Leave empty
Function Parameters
+ Add Parameter
Parameter #1
prompt
str
The prompt instruction for the MCP Client Agent, describing what to do in Jira.
Source Code
import core

def jira_mcp_client_tool(prompt):
    prompt = [prompt]
    print(f"Calling Jira MCP Client with prompt: {prompt}")
    configuration = "OpenAI GPT-4.1"
    mcp_config = "group_1_jira_secret"

    result = core.mcp_jira_init(prompt, configuration, mcp_config)
    return result

core.output(jira_mcp_client_tool(**core.args()))

Create Jira MCP Agent

Now let's create the Utility Agent that uses this tool — it interprets the incoming request and calls jira_mcp_client_tool with a well-formed prompt.

Show utility agent
Name
Jira MCP Client Agent
Description
Interprets a request and executes it in Jira via the Jira MCP tool.
Tags (optional)
Leave empty
Tools (add the tool above)
jira_mcp_client_tool
Detail (system prompt)
You are a Jira orchestration agent. You receive input from an upstream agent (or user) — this may be user stories, bugs, epics, tasks, questions about a Jira board, requests to update or read issues, or any other Jira-related content. Your job is to interpret the intent, structure it into a clear, complete instruction, and execute it by calling the `jira_mcp_client_tool` with a well-formed `prompt`.

## How you operate
Reason about the input, then act by calling the tool. Follow this order:
1. **Interpret intent.** Read the input and determine what the user actually wants to happen in Jira. Do not assume the input is always "create issues." Common intents:
   - Create/push issues (user stories, bugs, epics, tasks, subtasks)
   - Query/read issues (search, list, retrieve details, count, filter)
   - Update issues (status, assignee, fields, comments, links)
   - Any combination or other Jira operation

2. **Apply reasonable assumptions.** Infer the obvious rather than asking. Examples:
   - Input is user stories + a board/project URL → intent is to create those stories on that board.
   - Input describes bugs with steps-to-reproduce → create bug-type issues.
   - Input is a question like "what's in progress?" + URL → it's a read/query.
   - If issue type is unstated but content clearly implies it (acceptance criteria → story; defect → bug; large initiative → epic), pick the fitting type.
   - Extract project key / board / URL / any identifiers from the input and carry them forward.
   - If a Jira URL, board, or project ID is not in the latest message but appears anywhere earlier in the conversation history, use that one — do not treat it as missing.

3. **Decide if clarification is TRULY needed.** Only stop to clarify when the request cannot proceed, specifically when:
- No Jira URL, board, or project identifier is present in the latest message OR anywhere in the conversation history, AND none can be reasonably inferred, OR
- The intent is genuinely ambiguous (e.g. content could equally be a create vs. an update and the choice materially changes the outcome).
Do NOT clarify for minor gaps you can reasonably assume (e.g. stories present + URL present = push the stories). Prefer proceeding.

If clarification is truly required: do NOT call the tool. Instead, construct a single concise clarification question that states exactly what is missing (e.g. the target board URL) and why it's blocking, then HANDOFF to the previous (upstream) agent, passing that question so it can ask the user for the needed information. Do not attempt to proceed until the answer is provided.

4. **Structure and execute.** When you can proceed, construct a single clear natural-language `prompt` string that fully encapsulates the request, and call `jira_mcp_client_tool(prompt=...)`. The prompt you pass must be self-contained — the receiving side has no other context. Include:
   - The operation to perform (create / read / update / etc.), stated explicitly.
   - The target: URL, project key, board, or issue keys — whatever identifiers you extracted.
   - The full content: for creates, every issue with its type, summary, description, acceptance criteria, priority, labels, and any parent/epic links present in the input; for reads, the exact query/filter; for updates, which issues and which fields change to what.
   - Any assumptions you made, stated inline so the operation is unambiguous.

5. **Return the result.** After the tool returns, relay the outcome (created issue keys, query results, confirmation of updates, or errors) back clearly, then HANDOFF to the next Agent - your job is done.

## Rules
- Preserve all substantive detail from the input — do not drop acceptance criteria, reproduction steps, field values, or relationships.
- One tool call per coherent request unless the input clearly contains independent operations.
- Faithfully represent the user's intent; when you assume, make the assumption explicit in the prompt rather than silently guessing.

## Examples of the `prompt` you construct
- Create: "On the Jira board at <URL>, create the following user stories as Story-type issues. Story 1: <summary> — Description: <...> — Acceptance Criteria: <...>. Story 2: ..."
- Read: "On the Jira project <KEY> at <URL>, list all issues currently in the 'In Progress' status, returning key, summary, and assignee."
- Update: "In project <KEY>, update issue <KEY-123>: set status to Done and add a comment: <...>."

Integrate with Agent

Now we'll integrate the Jira MCP Utility Agent into our Super Agent from the previous labs.

Connect to User Story Agent

Open the User Story Super Agent and drop the Jira Agent onto the Flow canvas, connecting it between the User Story Creator and the Send to CUI utility agents.

Flow Start User Story Creator Jira MCP Client Agent Send to CUI End

Now test the same agent from the Persona we made in CUI, with the following prompt.

Note: Make you substitute the Jira board URL in the below prompt with the actual URL from the provided Excel for your group.
Show prompt
ACME Inc. is modernising its personal banking mobile application to improve customer retention and reduce call centre volume. The first initiative focuses on account visibility — customers should be able to view their current balances across all linked accounts and browse recent transactions without needing to contact support. The app must support both current and savings accounts, display at least 90 days of transaction history, and clearly indicate pending versus settled transactions. The solution must comply with ACME's internal data privacy standards and be accessible to users with visual impairments.

Help me create and push 2 user stories into my Jira Board. Then create a test plan.

Jira Board URL:
<SUBSTITUTE URL HERE>

Now you can monitor your Jira board and see the generated stories populate when the agent completes that step!

Human in the Loop

Sometimes we may want an Agent to pause and wait for user input partway through its run.

For example, once the user stories are generated, we might want the user to weigh in — are they good? Should they be regenerated? And if they're happy with them, is it good to push them to Jira?

We handle this with a human-in-the-loop step that pauses the agent's execution in Agent Manager and waits for input from the user in CUI before carrying on with the rest of the flow.

To do this, we need two pieces:

  1. A wait step in Agent Manager (a utility agent and a tool) that pauses and waits for a reply.
  2. A reply tool for the Persona in CUI that sends the user's answer back.

AM Component

If you recall from Integration, communication between Agent Manager and CUI is through API calls. Meaning, we'll need to:

  1. Create a tool which will:
    1. Send a message to CUI,
    2. Pause the execution, and
    3. Wait for a reply from CUI.
  2. Create a utility agent which will leverage the tool.

Start by creating this new tool:

Show tool
Name
ask_for_user_input_cui
Description
Send a message to CUI on behalf of the agent, then wait for the user's reply.
Tags (optional)
Leave empty
Function Parameters
+ Add Parameter
Parameter #1
message
str
The message content to send to user.
Source Code
import requests
import core
import typing

def ask_for_user_input_cui(
    message: typing.Annotated[str, "The message content to send to user."] = None
):
    """Send a message to CUI on behalf of the agent, then wait for the user's reply."""

    # Get CUI details from AM Secrets Manager
    credentials = core.get_secret(name="atr")
    url_base = credentials.get("url")
    recipient_id = core.get_local_value("recipient_id")
    conversation_id = core.get_local_value("cui_conversation_id")

    # CUI Auth
    auth_resp = requests.post(
        f"{url_base}/atr-gateway/identity-management/api/v1/auth/short-token",
        json={"username": credentials.get("username"), "password": credentials.get("password")},
        headers={"Content-Type": "application/json"},
        timeout=30,
    )
    auth_resp.raise_for_status()
    token = auth_resp.json()["token"]

    # Send message with reply enabled
    send_resp = requests.post(
        f"{url_base}/atr-gateway/genai/messages",
        json={
            "conversation_id": conversation_id,
            "content": message,
            "recipient_id": recipient_id,
            "push_message_metadata": {
                "system_name": "agent_manager",
                "system_awaiting_reply": True,
                "system_response_id": core.trace_id(),
            },
        },
        headers={"apiToken": token},
        timeout=30,
    )
    send_resp.raise_for_status()
    print(f"Sent message to CUI. Status: {send_resp.status_code}")

    # Wait for reply
    try:
        user_input = core.user_input(timeout=21600)  # Wait for user input for up to 6 hours (21600 seconds)
    except Exception as e:
        print(f"User input timeout: {e}")
        return "User Input Timeout - Handoff to END"

    return f"Received user input: {user_input}"

core.output(ask_for_user_input_cui(**core.args()))

Then create the utility agent:

Show utility agent
Name
Approval Agent
Description
Presents content to the user for approval and routes the flow based on their reply.
Tags (optional)
Leave empty
Tools (add the tool above)
ask_for_user_input_cui
Detail (system prompt)
You are an approval-gating agent. Your sole purpose is to present content to the user via the `ask_for_user_input_cui` tool, ask them for a decision (typically approval, or a request to regenerate/change something), and then route the flow based on their reply.

## What you receive
You may be handed content from a previous agent — user stories, a plan, generated Jira issues, a query result, a draft, or any other output awaiting sign-off. You may also be handed one or more specific questions to ask.

## How you operate
1. **Present the content faithfully.** Send the handed-in content to the user using `ask_for_user_input_cui`. You MUST NOT alter, summarize, add to, or drop any of the substantive content. The only transformation allowed is structural reorganization into clean Markdown for readability (headings, lists, tables) — the wording and information must remain identical.

2. **Append the question(s).** After the content, add the question(s) at the bottom of the message. If specific questions were handed to you, use them. If none were provided, default to asking the user to approve the content or request changes — make it clear they can approve, reject, or give further instructions.

3. **Send and wait.** Call `ask_for_user_input_cui` with this composed message. The tool blocks and returns the user's reply.

4. **Interpret the reply and route strictly:**
   - If the user **approves** (clear affirmative — "approve", "yes", "looks good", "go ahead", etc.): **HANDOFF to the NEXT agent**, passing along the original content you were handed (unmodified) together with the user's approval reply. Do NOT hand off back to the agent that handed off to you.
   - If the user **rejects**, asks to **regenerate**, requests **any change**, asks a question, or gives **any instruction other than plain approval**: **HANDOFF to the PREVIOUS agent** (the one that handed off to you), passing along the user's full reply so it can act on the feedback.
   - If the tool returns a timeout or no usable input: HANDOFF to END.

## Rules
- Never make the approval decision yourself — only the user's reply determines routing.
- Approval → NEXT. Anything else → PREVIOUS. There are no other paths (except timeout → END).
- Do not modify the content's meaning; Markdown formatting is the only permitted change.
- Pass the user's reply verbatim when handing back to the previous agent so it has full context.

When done, you can add this to the User Story Super Agent.

Give the Approval Agent a bidirectional connection with the User Story Creator — so when the user rejects or asks for changes, the flow hands back to regenerate, then returns to the Approval Agent.
Flow Start User Story Creator Approval Agent Jira MCP Client Agent Send to CUI End

Now trigger the agent from CUI or Agent Manager itself to quickly test the User Input feature. Monitor the Huddle Trace and watch closely for the flow to reach the Approval Agent.

We can test the user input feature by replying directly in the Huddle Trace, before we integrate on the CUI side.

Trace Overview Super Agent User Story Super Agent Utility Agent User Story Creator Tool fetch_org_docs Utility Agent Approval Agent Tool ask_for_user_input_cui 1m 11s 12s 35s 15s 6s 3s

Once the Huddle Trace reaches the above state, click the most recent trace for the ask_for_user_input_cui tool. This shows a Give Input button you can click to enter a message to send to the agent. You can also click the Utility Agent trace that called this tool to view the message that would be sent to the user.

CUI Component

We now need to implement the user input functionality on the CUI side to integrate with Agent Manager. Recall how the two communicate via API — this means we need to:

  1. Create a new tool in CUI that invokes a different Agent Manager endpoint, which sends the reply message back — just like we did manually in the Huddle Trace UI.
  2. Add this tool to the existing Persona and adjust its system prompt on how to use the tool.

Start by creating the tool in CUI:

Show tool
Name
reply_to_agent_manager
Type
Tool
Description
Sends the user's reply back to the waiting agent in Agent Manager.
Parameters
Parameter #1
Name
MESSAGE
Type
String
Required
Description
The user's reply, verbatim, concatenated and structured but not summarized.
Parameter #2
Name
TRACE_ID
Type
String
Required
Description
The most recent trace ID found in the conversation history, typically in the user's latest message.
Python Script
import httpx

from app.domain.tools.utils.context import context_util_helper
from app.models.canvas_document import CanvasDocument

API_URL = config_service.get("agentmanager-api-url","")
API_KEY = config_service.get("agentmanager-api-key","")

constructed_message = "User Reply: " + MESSAGE

async def reply_to_agent_manager(MESSAGE, TRACE_ID):
    headers = {
        "Content-Type": "application/json",
        "x-api-key": API_KEY,
    }
    data = {
        "query": """mutation SendUserResponse($traceId: String!, $message: String!) {
  sendUserResponse(traceId: $traceId, message: $message) {
    message
    __typename
  }
}""",
        "variables": {
            "traceId": TRACE_ID,
            "message": constructed_message,
        },
    }

    # Debug logging
    print(f"DEBUG - trace_id: {TRACE_ID}")
    print(f"DEBUG - message: {MESSAGE}")
    print(f"DEBUG - headers: {headers}")
    print(f"DEBUG - data: {data}")

    async with httpx.AsyncClient(timeout=20) as client:
        response = await client.post(API_URL, headers=headers, json=data)
        print(f"DEBUG - status_code: {response.status_code}")
        print(f"DEBUG - response headers: {dict(response.headers)}")
        print(f"DEBUG - response text: {response.text}")

        response.raise_for_status()

        response_json = response.json()
        print(f"DEBUG - response JSON: {response_json}")

        if "errors" in response_json:
            raise ValueError(f"GraphQL errors: {response_json['errors']}")

        return "Your message has been sent successfully to Agent Manager. Please wait while the Agent continues its tasks."

# Run the async function
if TRACE_ID is None:
    return "Trace ID is required to send a message to Agent Manager."
if not MESSAGE:
    return "Message is required to send a message to Agent Manager."

try:
    result = await reply_to_agent_manager(MESSAGE, TRACE_ID)
    return str(result)
except Exception as e:
    return f"Exception occurred: {e}"

Now navigate to your existing Persona, give it the new tool you made, and add this to the existing prompt:

Show prompt
If you see any message from the user exactly like: Reply to agent_manager ; id — use the ID provided in that message to run the `reply_to_agent_manager` tool, along with the message. Throughout the conversation the user may request to reply multiple times at different points, so make sure to use this tool whenever you see Reply to agent_manager ; id, with whichever ID is in that message.

And that's it!

Trigger the agent once again and monitor CUI for the message that gets sent from Agent Manager. You'll see it pop up looking like this:

Important: You MUST click the reply button to the right of the message before entering your message. It's a common oversight by end users, but it's required so that the Trace ID gets populated in the message properly.

Back in 2020/2021, we saw LLM chatbots like ChatGPT and Claude come to life — just chat interfaces for interacting with the underlying LLM.

In 2023, we got the ability to give these LLMs tools, letting them interact with external systems through APIs — this gave rise to the concept of an "agent".

In 2025, Claude Code took the world by storm and coined the term "agentic harness" — in a nutshell, it's what lets Claude Code browse files, run commands, and interact with code directly and autonomously. Codex, Antigravity CLI, and OpenCode are all similar agentic harnesses out there in the market.

Claude Code src app.py utils.py config.yaml tests test_app.py conftest.py README.md I can do anything!

What does this mean?

The possibilities are endless — Claude Code can read and write files, run code and scripts, and more. This opens the door to use cases such as:

  • Forward engineering / code generation / upgrades
  • Reverse engineering / documentation generation
  • Change impact analysis
  • And more…

Why is this relevant?

In Agent Manager, we have the ability to leverage Claude Code as a tool — meaning anything you can do locally with Claude Code, we can implement in Agent Manager as an automation.

Mesh Code

In Agent Manager, there is a pre-made function/tool called Mesh Code, which runs Claude Code underneath.

Start by creating the following tool:

Show tool
Name
run_mesh_code
Description
Runs a conversation-persistent instance of Claude Code with the given prompt.
Tags (optional)
Leave empty
Function Parameters
+ Add Parameter
Parameter #1
prompt
str
The prompt to send to Claude Code
Source Code
import core
import os

def get_repository_dir() -> str:
    repository_dir = core.get_local_value("repository_dir")

    if not repository_dir:
        project_dir = "./project"
        core.set_local_value("project_dir", project_dir)

        repository_dir = "./project/repository"
        core.set_local_value("repository_dir", repository_dir)
        os.makedirs(repository_dir, exist_ok=True)

        # Create a .gitkeep file in the repository directory to ensure it's tracked
        gitkeep_path = os.path.join(repository_dir, ".gitkeep")
        with open(gitkeep_path, "w") as f:
            f.write("")  # Empty file

    repository_dir = os.path.expanduser(repository_dir)

    return repository_dir


result = core.run_mesh_code(
    repository_dir=get_repository_dir(),
    prompt=core.args().get("prompt"),
    secret_name="Bedrock Sonnet 4.5",
    new_conversation=False
)

core.output(result)

Behind the scenes, this is how it works in Agent Manager:

Utility Agent* ReAct Agent Mesh Code Tool Python Script Claude Code Agentic Harness Linux Filesystem ECS Container calls prompt interacts

* Utility Agent is optional; you can set a static prompt for Mesh Code as a standalone tool in the Flow of a Super Agent.

The Mesh Code tool spawns an instance of Claude Code. Whatever you pass to the tool's prompt parameter goes straight to that instance, which then behaves just like local Claude Code. It runs on an ephemeral Linux filesystem that spins up alongside the agent execution.

But how do we put a codebase or documents into the Linux filesystem for Claude Code to see/interact with when it is invoked?

We do this by having a Utility agent run a tool before Mesh Code, which will download and load a codebase or any files provided by the user into the Linux Filesystem, where Claude Code will be spawned.

This is what we'll cover in the next section before testing Mesh Code.

Reverse Engineering Agent

In this lab, we'll create a Super Agent that generates documentation for a provided codebase.

The end outcome we're aiming for is something like this:

Flow Start Save Variables Agent Artifact Download Agent run_mesh_code * generate_artifacts_url Send to CUI End

* A static prompt is set in the run_mesh_code tool, so Claude always runs the same prompt for the agent, regardless of the codebase.

The Save Variables and Send to CUI Utility Agents we've already made in the previous labs, so we'll be reusing those.

We'll build this up over the next few sections — loading a codebase into the filesystem, running Mesh Code against it, then generating a downloadable URL for the result.

Load Codebase from Zip

In this section, we'll focus on loading a zipped codebase specifically for Mesh Code.

The CUI tool we implemented already supports uploading files to the Uploaded Artifacts tab in Agent Manager — we just need to load it into the filesystem, as mentioned in the previous section.

Start by creating the following tool, which loads an uploaded artifact — provided the file exists and the given filename matches:

Show tool
Name
dl_uploaded_artifact_for_mesh
Description
Downloads an uploaded artifact into the repository directory; zip files are extracted, other file types saved directly.
Tags (optional)
Leave empty
Function Parameters
+ Add Parameter
Parameter #1
filename
str
The filename of the artifact to download. Zip files are extracted; all other file types are saved directly.
Source Code
import typing
import requests
import core
import io
import os
import zipfile

def dl_uploaded_artifact_for_mesh(
    filename: typing.Annotated[str, "The filename of the artifact to download. Zip files are extracted; all other file types are saved directly."] = None):
    """
    Download a uploaded artifact. Zip files are extracted into the repository directory;
    all other file types (CSV, PDF, DOCX, etc.) are saved directly to the repository directory.

    Args:
        filename: The filename of the uploaded artifact to download

    Returns:
        str: Success message or error description
    """
    import shutil

    if not filename:
        raise ValueError("Filename parameter is required and cannot be None or empty.")

    project_dir = "./project"
    core.set_local_value("project_dir", project_dir)

    repository_dir = "./project/repository"
    core.set_local_value("repository_dir", repository_dir)

    try:
        os.makedirs(repository_dir, exist_ok=True)
    except Exception as e:
        raise Exception(f"Failed to create directory {repository_dir}: {e}")

    try:
        file = core.get_artifact(name=filename, type='uploaded', text=False)
    except Exception as e:
        raise Exception(f"Failed to download artifact '{filename}': {e}")

    # If the file is a zip file, extract it; otherwise, save it directly
    if filename.lower().endswith('.zip'):
        try:
            with zipfile.ZipFile(io.BytesIO(file)) as zip_ref:
                zip_ref.extractall(repository_dir)

            macosx_path = os.path.join(repository_dir, "__MACOSX") # Remove remnant __MACOSX folder
            if os.path.exists(macosx_path):
                shutil.rmtree(macosx_path)
        except Exception as e:
            raise Exception(f"Failed to extract '{filename}' to {repository_dir}: {e}")

        return f"Successfully downloaded and extracted '{filename}' to {repository_dir}."

    # Not Zip file, save directly
    else:
        try:
            file_path = os.path.join(repository_dir, filename)
            with open(file_path, 'wb') as f:
                f.write(file)
        except Exception as e:
            raise Exception(f"Failed to save '{filename}' to {repository_dir}: {e}")

        return f"Successfully downloaded '{filename}' to {file_path}."

core.output(dl_uploaded_artifact_for_mesh(**core.args()))

Then create the Utility Agent:

Show utility agent
Name
Artifact Download Agent
Description
Downloads every uploaded file referenced in its input, then hands off.
Tags (optional)
Leave empty
Tools (add the tool above)
dl_uploaded_artifact_for_mesh
Detail (system prompt)
You are an artifact download agent.
Your job is to download every uploaded file referenced in your input using the `dl_uploaded_artifact_for_mesh` tool, then hand off.

## How you operate
1. **Find all filenames.** Scan the input for every filename (e.g. fields like file1, file2, or any value that names an uploaded artifact such as "abc.docx", "123.zip").

2. **Download each one.**
Call `dl_uploaded_artifact_for_mesh(filename=...)` once per filename. Use the tool as many times as there are files — one call per file. Wait for each to succeed before proceeding to the next.

3. **Hand off.**
Once all files are downloaded, HANDOFF to the NEXT agent, passing the EXACT original input verbatim — the same JSON, with no modifications, no summarization, no added or removed fields.

## Rules
- One tool call per filename; do not batch multiple filenames into one call.
- Only download files actually named in the input; ignore non-file fields (e.g. user_input).
- Do not alter the input in any way when handing off — pass it through exactly as received.
- If a download fails, report the error rather than silently continuing.

Now move on to the next section.

Generate Artifact URL

Once Mesh (Claude) Code has generated code or documents, the Agent will need a way to send this to the end user. Large/not-purely-text artifacts like code need a downloadable URL to send to the user.

You can do this with the following tool, which will upload any generated artifacts in the filesystem to S3 and return a presigned URL — add it to Agent Manager:

Show tool
Name
generate_artifacts_url
Description
Zips the repository, uploads it to S3, and returns a presigned download URL.
Tags (optional)
Leave empty
Function Parameters
+ Add Parameter
No parameters — this tool takes no arguments.
Source Code
import os
import uuid
import zipfile
import datetime
import core

def generate_artifacts_url():
    """Zip repository_dir, upload to S3, and return a presigned URL.

    Returns:
        A presigned S3 URL string on success, or an ERROR string on failure.
    """
    repository_dir = core.get_local_value("repository_dir")
    artifact_path = "full_artifact.zip"
    artifact_prefix = "artifacts/{artifact_id}"

    exclude_dirs = {"node_modules"}

    def zip_folder(folder_path, zip_file_name):
        if not os.path.isdir(folder_path):
            raise FileNotFoundError(f"The folder {folder_path} does not exist.")

        with zipfile.ZipFile(zip_file_name, "w", zipfile.ZIP_DEFLATED) as zipf:
            for root, dirs, files in os.walk(folder_path):
                dirs[:] = [d for d in dirs if d not in exclude_dirs]
                for file in files:
                    file_path = os.path.join(root, file)
                    arcname = os.path.relpath(file_path, folder_path)
                    zipf.write(file_path, arcname)

        print(f"Zipped {folder_path} into {zip_file_name}.")

    try:
        print(f"Zipping repository: {repository_dir}")
        zip_folder(repository_dir, artifact_path)

        # Random Unique ID - file_4charHex_HHMMSS
        artifact_id = f"artifacts_{uuid.uuid4().hex[:4]}_{datetime.datetime.now().strftime('%H%M%S')}.zip"
        key = artifact_prefix.format(artifact_id=artifact_id)

        print("Uploading to S3...")
        core.upload_generated_artifact(key, filename=artifact_path)

        print("Generating presigned URL...")
        artifact_url = core.get_generated_artifact_url(artifact_id)

        core.set_local_value("final_artifact_url", artifact_url)

        print(f"Generated Artifact URL: {artifact_url}")
        return artifact_url

    except Exception as e:
        return f"Error generating artifacts: {e}"

core.output(generate_artifacts_url(**core.args()))

Assemble the Agent

Now create a new Super Agent and combine all of the pieces we've made during this lab.

We also need to add a static prompt for the run_mesh_code tool. Do this by clicking on the tool after you've dragged it in.

Flow Start Save Variables Agent Artifact Download Agent run_mesh_code Click generate_artifacts_url Send to CUI End
Tool Detail
Alias
Optional – Leave empty
Custom Tool Inputs
Prompt
Analyze this codebase and generate a Markdown document of its architecture.

* A static prompt is set in the run_mesh_code tool, so Claude always runs the same prompt for the agent, regardless of the codebase.

Now you can test it in either of two ways:

  1. Test directly in Agent Manager:
    1. Navigate to the Uploaded Artifacts tab.
    2. Upload a zipped codebase.
    3. Give the file a name (preferably the same name as the file itself, with the file extension — e.g. abc.zip).
    4. Run the Agent and give the name of the file you uploaded as input.
  2. Create a tool and persona in CUI, then upload the file in CUI and trigger.

You can use the following sample codebase for your testing:

Click to download sample Retail Billing System codebase

And that's it!

This is basically the core flow that constitutes most of the Foundation Agents when it comes to reverse / forward engineering.

Meaning you could simply change the prompt for the run_mesh_code tool to do other things — for example:

  • Security review and fix implementation
  • Module/Library version upgrade code impact analysis
  • UI/UX Enhancement

You can also chain run_mesh_code tool nodes sequentially with different prompts to form a pipeline — for example, Implement CR → Security Review → Generate Documentation.

Accuracy and expected output may be a different story though. If you had a 10-million-LOC, poorly written codebase, your mileage may obviously vary — but that barrier is decreasing by the day with newer and stronger LLM models.

You may also employ strategies like RAG, Knowledge Graph and existing documentation to improve results.

Github Integration

Publishing

Coming soon.

Glossary

Coming soon.

Hi, I'm Avan

AI Automation Engineer in Client Delivery

Over the past year and a half, I've been using Agent Manager and CUI to build use cases for clients such as:

nbn
Sony
Department of Health
AGL Energy
ASX
Client logo
Telefónica
Transport for NSW
Olympus
Eisai