<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>Custom agent with custom human-in-the-loop form | Sample | ArcGIS Maps SDK for JavaScript</title>
<script type="module" src="https://js.arcgis.com/5.2/"></script>
border-top: 1px solid var(--calcite-color-border-2);
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
display: none !important;
<arcgis-map id="maintenance-map" item-id="394f946eac284ce0b5a93fdc194c5430">
<arcgis-zoom slot="top-left"></arcgis-zoom>
<arcgis-search slot="top-right"></arcgis-search>
<calcite-shell-panel slot="panel-end" width="l" height-scale="l">
reference-element="#maintenance-map"
heading="Report a road issue"
description="Please provide a description of a road issue including its location."
entry-message="What road issue would you like to report?"
always-use-agent-id="maintenance"
<form id="review-form" slot="interrupt" hidden>
<h2>Review maintenance report</h2>
<div class="report-fields">
<calcite-select id="category">
<calcite-option value="Pothole">Pothole</calcite-option>
<calcite-option value="Cracks">Cracks</calcite-option>
<calcite-option value="Faded Paint">Faded Paint</calcite-option>
<calcite-option value="Debris">Debris</calcite-option>
<calcite-option value="Erosion">Erosion</calcite-option>
<calcite-option value="Blockage">Blockage</calcite-option>
<calcite-option value="Other">Other</calcite-option>
<calcite-select id="severity">
<calcite-option value="Low">Low</calcite-option>
<calcite-option value="Medium">Medium</calcite-option>
<calcite-option value="High">High</calcite-option>
<calcite-option value="Critical">Critical</calcite-option>
<calcite-text-area id="notes" rows="3" max-length="250"></calcite-text-area>
<calcite-input id="address" type="text"></calcite-input>
<calcite-button id="resolve-address" type="button" icon-start="pin" appearance="outline" hidden>
<p id="location-summary"></p>
<calcite-notice id="review-error" kind="danger" icon width="full">
<span slot="message" role="alert"></span>
<div class="report-actions">
<calcite-button id="confirm" type="submit" icon-start="check">Confirm and save</calcite-button>
<calcite-button id="cancel" type="button" icon-start="x" appearance="outline">Cancel</calcite-button>
{ createHumanInTheLoopToolMiddleware },
] = await $arcgis.import([
"@arcgis/core/Graphic.js",
"@arcgis/core/rest/locator.js",
"@arcgis/ai-components/agent-utils/SkillAgent.js",
"@arcgis/ai-components/agent-utils/tools/FunctionTool.js",
"@arcgis/ai-components/agent-utils/createSchema.js",
"@arcgis/ai-components/agent-utils/middlewares/humanInTheLoop.js",
const mapElement = document.querySelector("arcgis-map");
const assistant = document.querySelector("arcgis-assistant");
const form = document.querySelector("#review-form");
const category = document.querySelector("#category");
const severity = document.querySelector("#severity");
const notes = document.querySelector("#notes");
const address = document.querySelector("#address");
const confirm = document.querySelector("#confirm");
const resolveButton = document.querySelector("#resolve-address");
const locationSummary = document.querySelector("#location-summary");
const errorNotice = document.querySelector("#review-error");
const panel = document.querySelector("calcite-shell-panel");
// accommodate responsive panel layout for mobile screens
const narrowScreen = window.matchMedia("(max-width: 760px)");
function updatePanelLayout() {
panel.slot = narrowScreen.matches ? "panel-bottom" : "panel-end";
panel.layout = narrowScreen.matches ? "horizontal" : "vertical";
narrowScreen.addEventListener("change", updatePanelLayout);
let pendingReport; // Indicates if there is a report pending review
let reviewedReport; // Stores the report that has been reviewed
let preview; // Graphic used to preview the location on the map
let resolving = false; // Indicates if the address resolution interrupt is ongoing
const categories = ["Pothole", "Cracks", "Faded Paint", "Debris", "Erosion", "Blockage", "Other"];
const severities = ["Low", "Medium", "High", "Critical"];
// Schema for the maintenance report
const inputSchema = await createSchema({
category: { type: "string", enum: categories },
severity: { type: "string", enum: severities },
notes: { type: "string", description: "Issue details in 250 characters or fewer." },
address: { type: "string", description: "The street address and city provided by the user." },
required: ["category", "severity", "notes", "address"],
function showError(message) {
errorNotice.querySelector("[slot='message']").textContent = message;
errorNotice.open = !!message;
async function resolveAddress(value) {
const candidates = await locator.addressToLocations(
"https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer",
{ address: { SingleLine: value }, maxLocations: 1, outSpatialReference: { wkid: 4326 } },
const match = candidates[0];
if (!match?.location || match.score < 85) {
throw new Error("Enter a more precise street address and city.");
return { address: match.address, location: match.location };
// Preview the location on the map with a graphic and update the location summary
async function previewLocation(report) {
if (preview) mapElement.graphics.remove(preview);
geometry: report.location,
symbol: { type: "simple-marker", color: "#247456", size: 14, outline: { color: "white", width: 2 } },
mapElement.graphics.add(preview);
locationSummary.textContent = `Location: ${report.location.latitude.toFixed(6)}, ${report.location.longitude.toFixed(6)}`;
await mapElement.goTo({ target: report.location, zoom: 17 });
const addressChanged = address.value.trim() !== pendingReport?.address;
categories.includes(category.value) &&
severities.includes(severity.value) &&
notes.value.trim().length > 0 &&
notes.value.length <= notes.maxLength &&
confirm.disabled = resolving || !valid;
resolveButton.hidden = !addressChanged;
resolveButton.disabled = resolving || !address.value.trim();
return !confirm.disabled;
* Tool for reporting road maintenance issues.
* Allows the user to preview, edit, and confirm the report before submission.
const reportTool = new FunctionTool({
name: "reportMaintenance",
"Preview a road maintenance report, let the user edit and confirm it, and save the reviewed report.",
createHumanInTheLoopToolMiddleware({
interrupt: async ({ input }) => {
await mapElement.viewOnReady();
// input from the LLM that needs human review
pendingReport = { ...input, ...(await resolveAddress(input.address)) };
reviewedReport = undefined;
// Populate the form fields with the input values
// suggested by LLM for user review
category.value = input.category;
severity.value = input.severity;
notes.value = input.notes;
address.value = pendingReport.address;
// zoom to the location on the map for preview
await previewLocation(pendingReport);
// Show the form for user review and editing
// Return the interrupt object to display the human-in-the-loop form with final submission options
message: "Review and edit the maintenance report before confirming submission.",
execute: async ({ approval }) => {
if (preview) mapElement.graphics.remove(preview);
pendingReport = undefined;
if (approval !== "Yes") return "The report was cancelled. No feature was added.";
if (!reviewedReport) throw new Error("Review and confirm the report using the form before saving.");
const report = reviewedReport;
reviewedReport = undefined;
const result = await layer.applyEdits({
addFeatures: [new Graphic({ geometry: report.location, attributes: report.attributes })],
const added = result.addFeatureResults[0];
if (!added || added.error || added.objectId == null) {
throw new Error(added?.error?.message ?? "The service did not confirm that the report was saved.");
message: "Saved the user-reviewed report. These values may differ from the original extraction.",
objectId: added.objectId,
* Initialize the maintenance skill agent and set up event listeners for handling
* human-in-the-loop interactions, errors, and form validation.
const maintenanceAgent = new SkillAgent({
name: "Maintenance agent",
description: "Report road maintenance issues after human review and confirmation.",
systemPrompt: "You are a city public works assistant. Follow the maintenance skill.",
description: Report potholes, cracks, faded paint, debris, erosion, blockages, and other road hazards.
allowed-tools: reportMaintenance
- Extract category, severity, concise notes, and the user's street address and city.
- Use Other when the category is unclear and Medium when severity is unspecified.
- Ask for an address and city if missing. Never invent locations or use the map center.
- Call "reportMaintenance" once for one incident at a time. Never call tools in parallel.
- The tool pauses for an editable human review. Never supply approval yourself.
- The user may change any field. Treat the tool's returned values as authoritative.
- Only report success when the tool returns a saved objectId. Never retry a failed or rejected submission automatically.
- If address resolution fails, ask the user to clarify the address.`,
toolLoaders: { reportMaintenance: async () => reportTool },
// Only show the form during an interrupt.
assistant.addEventListener("arcgisInterrupt", () => {
// Hide the form initially.
assistant.addEventListener("arcgisInterruptSubmit", () => {
assistant.addEventListener("arcgisError", (event) => showError(event.detail.message));
// Allow form submission only when all fields are valid.
for (const [element, event] of [
[category, "calciteSelectChange"],
[severity, "calciteSelectChange"],
[notes, "calciteTextAreaInput"],
[address, "calciteInputInput"],
element.addEventListener(event, validate);
resolveButton.addEventListener("click", async () => {
const report = pendingReport;
resolveButton.loading = true;
const resolved = await resolveAddress(address.value.trim());
if (report !== pendingReport || !assistant.interrupt) return;
Object.assign(report, resolved);
address.value = report.address;
await previewLocation(report);
showError(error.message);
address.disabled = false;
resolveButton.loading = false;
* Prepare the reviewed report for submission to the feature layer.
form.addEventListener("submit", (event) => {
location: pendingReport.location.clone(),
category: category.value,
severity: severity.value,
notes: notes.value.trim(),
address: pendingReport.address,
CreationDate: Date.now(),
assistant.submitInterrupt("Yes");
document.querySelector("#cancel").addEventListener("click", () => assistant.submitInterrupt("No"));
await mapElement.viewOnReady();
const layer = mapElement.map.allLayers.find((item) => item.title === "Maintenance issues");
notes.maxLength = Math.min(250, layer.getField("notes").length || 250);
// Register and append the custom agent element to the assistant
const agentElement = document.createElement("arcgis-assistant-agent");
agentElement.agent = maintenanceAgent;
assistant.append(agentElement);
assistant.suggestedPrompts = ["Report a pothole at 3200 E Tahquitz Canyon Way, Palm Springs, CA."];