diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs
index 5bb15b456e..443cbdfa14 100644
--- a/backend/windmill-store/src/resources.rs
+++ b/backend/windmill-store/src/resources.rs
@@ -467,12 +467,10 @@ async fn list_resources(
let v: serde_json::Value =
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
// ResourceEditor's `ResourceState`: { path, description, args, labels?, wsSpecific, resource_type? }
- let path = v
- .get("path")
- .and_then(|s| s.as_str())
- .unwrap_or("")
- .to_string();
- if path.is_empty() || !allowed(&path) {
+ // Listed at the draft's key rather than its `path`: an editor keys the draft on the path
+ // it opened, which is the only one a draft read can find, while `path` moves on rename.
+ let path = row.path;
+ if !allowed(&path) {
continue;
}
let description = v
diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte
new file mode 100644
index 0000000000..d9a5d26f31
--- /dev/null
+++ b/frontend/src/lib/components/common/table/AgentRow.svelte
@@ -0,0 +1,142 @@
+
+
+
+ {#snippet badges()}
+
+ dispatch('change')}
+ />
+
+ {/snippet}
+
+ {#snippet actions()}
+ {
+ const { path } = agent
+ return [
+ {
+ displayName: 'Permissions',
+ icon: Shield,
+ disabled: !agent.canWrite,
+ action: () => {
+ shareModal.openDrawer?.(path, 'resource')
+ }
+ },
+ {
+ displayName: 'Move/Rename',
+ icon: FolderInput,
+ disabled: !agent.canWrite || Boolean(agent.draft_only),
+ action: () => {
+ moveDrawer.openDrawer(path, agent.description, 'resource')
+ }
+ },
+ ...(!agent.ws_specific &&
+ !agent.draft_only &&
+ isDeployable('resource', path, await getDeployUiSettings())
+ ? [
+ {
+ displayName: 'Deploy to prod/staging',
+ icon: FileUp,
+ action: () => {
+ deploymentDrawer.openDrawer(path, 'resource')
+ }
+ }
+ ]
+ : []),
+ {
+ displayName: 'Delete',
+ icon: Trash,
+ type: 'delete' as const,
+ disabled: !agent.canWrite,
+ action: (event) => {
+ if (event?.shiftKey) {
+ deleteAgent(path)
+ } else {
+ deleteConfirmedCallback = () => {
+ deleteAgent(path)
+ }
+ }
+ }
+ }
+ ]
+ }}
+ on:open={() => {
+ menuOpen = true
+ }}
+ />
+ {/snippet}
+
diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte
index 4fb90151c1..76cd30640a 100644
--- a/frontend/src/lib/components/common/table/Row.svelte
+++ b/frontend/src/lib/components/common/table/Row.svelte
@@ -39,6 +39,7 @@
| 'flow'
| 'app'
| 'raw_app'
+ | 'agent'
| 'resource'
| 'variable'
| 'resource_type'
diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte
index 9f0395b924..a7c99345a3 100644
--- a/frontend/src/lib/components/common/table/RowIcon.svelte
+++ b/frontend/src/lib/components/common/table/RowIcon.svelte
@@ -8,6 +8,7 @@
import AzureIcon from '$lib/components/icons/AzureIcon.svelte'
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
import {
+ Bot,
Boxes,
Calendar,
Code2,
@@ -29,6 +30,7 @@
| 'app'
| 'raw_app'
| 'raw_app_file'
+ | 'agent'
| 'resource'
| 'variable'
| 'resource_type'
@@ -99,6 +101,8 @@
{:else if effectiveKind === 'script'}
+ {:else if effectiveKind === 'agent'}
+
{:else if effectiveKind === 'variable'}
{:else if effectiveKind === 'resource'}
diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts
index 5ee65cd98a..0499fbfd9b 100644
--- a/frontend/src/lib/components/flows/agentDraft.svelte.ts
+++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts
@@ -181,6 +181,8 @@ export interface AgentDraftOptions {
/** The `ai_agent` resource being edited. */
path: () => string | undefined
workspace: () => string | undefined
+ /** A missing row is a new agent to start empty, not a load failure. */
+ isNew?: () => boolean
}
export interface AgentDraftHandle {
@@ -327,10 +329,26 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
await sync.maybeRestore()
},
(err) => {
+ if (loadedFor !== key) return
+ // Nothing is written until the first edit: the sync saves only on user input, and
+ // the first deploy creates the resource at whatever path the form then holds.
+ if (opts.isNew?.() && (err as { status?: number })?.status === 404) {
+ noDeployed = true
+ deployed = undefined
+ canWriteResource = true
+ state = {
+ path,
+ description: '',
+ args: {},
+ resource_type: 'ai_agent',
+ wsSpecific: false
+ }
+ loading = false
+ return
+ }
// A failed load knows neither the resource's type nor its value, so it refuses:
// clearing `loading` alone would let the sync restore a persisted draft into a form
// that would then deploy over a resource nobody read.
- if (loadedFor !== key) return
refuse(`Could not load agent ${path}: ${err}`)
}
)
diff --git a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts
index 025a426d28..0488796c76 100644
--- a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts
+++ b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts
@@ -18,6 +18,9 @@ export interface AgentEditorTarget {
/** Where to re-resolve a graph's tool nodes after a deploy, when opened from a flow step. Only a
* real flow sets it: the agent editor offers no way to open a second editor from inside itself. */
host?: { flowPath: string; moduleId: string }
+ /** The path was minted for an agent that does not exist yet: a missing row is an empty agent to
+ * start from rather than a load failure. */
+ isNew?: boolean
}
let target = $state(undefined)
diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte
index 4f69d3a1a1..2e28d7ba8c 100644
--- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte
+++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte
@@ -61,6 +61,8 @@
onSelectTool?: (toolId: string | undefined) => void
/** Ran after a successful deploy, with the path actually written, which a rename can move. */
onSaved?: (path: string) => void | Promise
+ /** The path was minted for a new agent, so a missing row starts an empty one. */
+ isNew?: boolean
}
let {
@@ -69,7 +71,8 @@
enableAi = false,
toolId = undefined,
onSelectTool = undefined,
- onSaved = undefined
+ onSaved = undefined,
+ isNew = false
}: Props = $props()
/** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a
@@ -78,7 +81,11 @@
* schema to the root's. */
const AGENT_ID = '__wm_agent_root'
- const draft = useAgentDraft({ path: () => path, workspace: () => workspace })
+ const draft = useAgentDraft({
+ path: () => path,
+ workspace: () => workspace,
+ isNew: () => isNew
+ })
/** Read access only. Everything that could write is blocked, down to the draft itself: an
* autosave the server rejects would look like a save and lose the edit. Running the agent,
@@ -447,6 +454,7 @@
}
bind:error={pathError}
initialPath={path}
+ checkInitialPathExistence={draft.noDeployed}
namePlaceholder="agent"
kind="resource"
workspaceOverride={workspace}
diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte
index ea45761bf9..e01bdc777e 100644
--- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte
+++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte
@@ -386,6 +386,7 @@
toolId={target.toolId}
onSelectTool={(id) => showAgentEditorTool(id)}
{onSaved}
+ isNew={target.isNew}
/>
{/snippet}
diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte
index 7c5bb4986e..8d398f7b9f 100644
--- a/frontend/src/lib/components/home/CreateActionsMenu.svelte
+++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte
@@ -8,6 +8,7 @@
import Tab from '$lib/components/common/tabs/Tab.svelte'
import {
Plus,
+ Bot,
Code2,
LayoutDashboard,
ChevronDown,
@@ -29,6 +30,7 @@
import YAML from 'yaml'
import type { Snippet } from 'svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
+ import { NEW_AGENT_HASH, RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
interface Props {
/** Replaces the default `New` button, e.g. with an inline text link. */
@@ -120,6 +122,21 @@
}
] as Option[])
: []),
+ {
+ key: 'agent',
+ label: 'AI agent',
+ icon: Bot,
+ accent: 'purple',
+ tagline: 'A reusable agent with tools',
+ description:
+ 'Pick a model, write the system prompt and give the agent tools: scripts, flows, MCP servers or other agents. Test it from the editor and link it from any flow.',
+ bullets: [
+ 'Any provider and model',
+ 'Tools from scripts, flows and MCP',
+ 'Shared across flows'
+ ],
+ onSelect: () => goto(`${base}${RESOURCES_PATH}${NEW_AGENT_HASH}`)
+ },
...(HOME_SHOW_CREATE_APP
? ([
{
diff --git a/frontend/src/lib/components/home/Item.svelte b/frontend/src/lib/components/home/Item.svelte
index f9e30eaf65..78dbf83a15 100644
--- a/frontend/src/lib/components/home/Item.svelte
+++ b/frontend/src/lib/components/home/Item.svelte
@@ -2,6 +2,7 @@
import AppRow from '../common/table/AppRow.svelte'
import FlowRow from '../common/table/FlowRow.svelte'
import RawAppRow from '../common/table/RawAppRow.svelte'
+ import AgentRow from '../common/table/AgentRow.svelte'
import ScriptRow from '../common/table/ScriptRow.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { Alert } from '$lib/components/common'
@@ -44,9 +45,10 @@
// A raw app the listing returns is an `app` row carrying `raw_app`, and is
// selectable like any other app. The separate `raw_app` type is the legacy
// listing shape, which renders through RawAppRow — no selection control there,
- // so it must not enter the selection either.
+ // so it must not enter the selection either. An agent is a resource underneath,
+ // which none of the bulk actions handle.
let bulkItem = $derived(
- homeSelection?.available && item.type !== 'raw_app'
+ homeSelection?.available && item.type !== 'raw_app' && item.type !== 'agent'
? toBulkItem(item, $userStore, $workspaceStore)
: undefined
)
@@ -132,6 +134,19 @@
bind:menuOpen
{keyboardSelected}
/>
+{:else if item.type == 'agent'}
+ dispatch('reload')}
+ agent={item}
+ {shareModal}
+ {moveDrawer}
+ {deploymentDrawer}
+ {depth}
+ bind:menuOpen
+ {keyboardSelected}
+ />
{/if}
{#if menuOpen}
diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte
index f86ec9d5bf..de65860e5e 100644
--- a/frontend/src/lib/components/home/ItemsList.svelte
+++ b/frontend/src/lib/components/home/ItemsList.svelte
@@ -10,8 +10,10 @@
type ListableApp,
type Script,
ScriptService,
+ ResourceService,
type Flow,
type ListableRawApp,
+ type ListableResource,
type RunnableItem
} from '$lib/gen'
import { resource } from 'runed'
@@ -20,6 +22,7 @@
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ArrowDownUp,
+ Bot,
ChevronsDownUp,
ChevronsUpDown,
Code2,
@@ -62,7 +65,7 @@
import BulkActionsBar from './BulkActionsBar.svelte'
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
interface Props {
- subtab?: 'flow' | 'script' | 'app'
+ subtab?: 'flow' | 'script' | 'app' | 'agent'
showEditButtons?: boolean
}
@@ -101,7 +104,8 @@
options: [
{ value: 'script', label: 'Script' },
...(HOME_SEARCH_SHOW_FLOW ? [{ value: 'flow', label: 'Flow' }] : []),
- { value: 'app', label: 'App' }
+ { value: 'app', label: 'App' },
+ { value: 'agent', label: 'Agent' }
]
},
archived: { type: 'boolean' as const, label: 'Only archived' },
@@ -166,7 +170,15 @@
// depends on these (search, kind, archived, library, user-folder scope), so changing a
// searchbar chip reloads the server stream exactly as toggling the old controls did.
let filter = $derived((filterValues.val._default_ ?? '') as string)
- let itemKind = $derived((filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'all')
+ let itemKind = $derived(
+ (filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'agent' | 'all'
+ )
+ // Agents are `ai_agent` resources, listed through the resource endpoint: the runnables
+ // endpoint has no such kind, so it is asked for nothing on the agent view and for every
+ // kind on the combined one.
+ let showsRunnables = $derived(itemKind !== 'agent')
+ let showsAgents = $derived(itemKind === 'all' || itemKind === 'agent')
+ let runnableKinds = $derived(itemKind === 'all' || itemKind === 'agent' ? undefined : itemKind)
let archived = $derived(!!filterValues.val.archived)
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
@@ -200,7 +212,7 @@
if (el) untrack(() => el.open())
})
- type TableItem = T & {
+ type TableItem = T & {
canWrite: boolean
marked?: string
type?: U
@@ -218,6 +230,7 @@
type TableFlow = TableItem
type TableApp = TableItem
type TableRawApp = TableItem
+ type TableAgent = TableItem
// Folders that are data pipelines, surfaced as their own "Pipeline" entry
// (the member scripts are folded into it, not listed individually). Two
@@ -303,10 +316,15 @@
let flows: TableFlow[] | undefined = $state()
let apps: TableApp[] | undefined = $state()
let raw_apps: TableRawApp[] | undefined = $state()
+ // Starts empty rather than undefined: the runnables page decides when the list renders,
+ // and agents join it when their own request lands.
+ let agents: TableAgent[] = $state([])
+ let agentOwnerCounts: Record = $state({})
+ let agentsGen = 0
// Monotonic fetch-order counter stamped onto each row as it arrives (see TableItem.ord).
let fetchOrd = 0
- let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
+ let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp | TableAgent)[] = $state([])
let loading = $state(true)
@@ -347,6 +365,66 @@
return base as unknown as TableFlow | TableApp
}
+ /**
+ * The workspace's saved agents, as the kind the home page shows them as. Read from the
+ * resource listing, which pages nothing and orders nothing, so the order is applied here and
+ * the rows are stamped after every runnable ordinal: a page of runnables keeps the server's
+ * order and the agents follow it as one block.
+ */
+ async function loadAgents(): Promise {
+ const ws = $workspaceStore
+ const gen = ++agentsGen
+ if (!ws || !$userStore || !showsAgents || archived) {
+ agents = []
+ agentOwnerCounts = {}
+ return
+ }
+ let rows: ListableResource[]
+ try {
+ rows = await ResourceService.listResource({
+ workspace: ws,
+ resourceType: 'ai_agent',
+ includeDraftOnly: true
+ })
+ } catch (e: any) {
+ if (gen !== agentsGen) return
+ sendUserToast(`Failed to load agents: ${e?.body ?? e?.message ?? e}`, true)
+ agents = []
+ agentOwnerCounts = {}
+ return
+ }
+ if (gen !== agentsGen) return
+ // Counted before the owner scope: the chips and tree nodes list every owner.
+ const counts: Record = {}
+ for (const r of rows) {
+ const owner = r.path.split('/').slice(0, 2).join('/')
+ counts[owner] = (counts[owner] ?? 0) + 1
+ }
+ agentOwnerCounts = counts
+ const scoped = ownerFilter ? rows.filter((r) => r.path.startsWith(ownerFilter + '/')) : rows
+ const byTime = (r: ListableResource) => new Date(r.edited_at ?? 0).getTime()
+ const sorted = [...scoped].sort((a, b) => {
+ switch (sortOrder) {
+ case 'updated_asc':
+ return byTime(a) - byTime(b)
+ case 'name_asc':
+ return cmp(a.path, b.path)
+ case 'name_desc':
+ return cmp(b.path, a.path)
+ default:
+ return byTime(b) - byTime(a)
+ }
+ })
+ agents = sorted.map((r, i) => ({
+ ...r,
+ summary: r.description || undefined,
+ canWrite: canWrite(r.path, (r.extra_perms ?? {}) as any, $userStore) && !$userStore?.operator,
+ ord: AGENT_ORD_BASE + i
+ }))
+ }
+ /** Above any ordinal a runnables page can reach, so agents sort after them as a block. */
+ const AGENT_ORD_BASE = 1_000_000
+
// The merged, server-ordered, keyset-paginated source. `reset` reloads from
// the first page (order/filter change or workspace switch); otherwise it
// appends the next page. All three kinds arrive interleaved and are split into
@@ -363,6 +441,20 @@
// arrays (mixing streams) or clobber the pending reset's generation.
if (!reset && serverCursor === undefined) return
if (scripts === undefined) loading = true
+ // One request per reset: the resource listing is not paged, so a load-more has
+ // nothing to append for agents.
+ if (reset) void loadAgents()
+ if (!showsRunnables) {
+ scripts = []
+ flows = []
+ apps = []
+ raw_apps = []
+ pipelineMemberFolders = new Set()
+ serverCursor = undefined
+ hasMoreServer = false
+ loading = false
+ return
+ }
if (reset) {
serverCursor = undefined
hasMoreServer = false
@@ -385,7 +477,7 @@
orderDesc,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
- kinds: itemKind !== 'all' ? itemKind : undefined,
+ kinds: runnableKinds,
// Selecting an owner/folder scopes the paged stream to it server-side,
// so a folder's full contents load on demand rather than relying on the
// folder happening to be within the loaded browse window.
@@ -598,7 +690,7 @@
orderDesc,
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
- kinds: itemKind !== 'all' ? itemKind : undefined,
+ kinds: runnableKinds,
pathStart: prefix,
includeDraftOnly: true,
perPage: OWNER_PAGE_SIZE,
@@ -714,7 +806,7 @@
}
function filterItemsPathsBaseOnUserFilters(
- item: TableScript | TableFlow | TableApp | TableRawApp,
+ item: TableScript | TableFlow | TableApp | TableRawApp | TableAgent,
filterUserFolders: boolean,
filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined
) {
@@ -968,6 +1060,7 @@
[() => $workspaceStore, () => archived, () => itemKind, () => includeWithoutMain],
async ([ws, showArchived, kind, withoutMain]) => {
if (!ws || showArchived) return undefined
+ if (kind === 'agent') return {}
try {
const res = await ScriptService.countRunnablesByOwner({
workspace: ws,
@@ -983,7 +1076,16 @@
}
}
)
- let ownerCounts = $derived(ownerCountsRes.current)
+ // The runnables endpoint never counts an agent, so the loaded agents are added per owner.
+ let ownerCounts = $derived.by(() => {
+ const runnables = ownerCountsRes.current
+ if (runnables == undefined) return undefined
+ const counts = { ...runnables }
+ for (const [owner, n] of Object.entries(agentOwnerCounts)) {
+ counts[owner] = (counts[owner] ?? 0) + n
+ }
+ return counts
+ })
// The counts decide which owners the tree renders, so drawing it before they land
// would show every workspace folder and then prune it away. Hold the skeleton
// until the first response instead — it is fetched in parallel with the listing,
@@ -1159,7 +1261,8 @@
const kind = itemKind
// Any term/scope change restarts search paging.
searchCursor = undefined
- if (term === '' || !ws || !$userStore) return
+ // Agents are all loaded, so the client-side search over them is already complete.
+ if (term === '' || !ws || !$userStore || kind === 'agent') return
const handle = setTimeout(async () => {
let res: { items: RunnableItem[]; next_cursor?: string }
try {
@@ -1215,7 +1318,7 @@
search: term,
showArchived: showArchived ? true : undefined,
includeWithoutMain: withoutMain ? true : undefined,
- kinds: kind !== 'all' ? kind : undefined,
+ kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined,
pathStart: owner ? owner + '/' : undefined,
includeDraftOnly: true,
perPage: 1000,
@@ -1266,6 +1369,11 @@
...x,
type: 'raw_app' as 'raw_app',
time: new Date(x.edited_at).getTime()
+ })),
+ ...agents.map((x) => ({
+ ...x,
+ type: 'agent' as 'agent',
+ time: new Date(x.edited_at ?? 0).getTime()
}))
].sort(compareItems)
)
@@ -1326,10 +1434,12 @@
// injected as a node and its rows come from the on-demand `treeOwnerItems` store,
// so the tree never depends on which owners happen to be in the loaded window.
// Otherwise (scoped/search/label) the tree just groups the global `items`.
+ // Agents ride along in lazy mode too: the owner store is fed by the runnables endpoint,
+ // which never returns one, while the resource listing already holds them all.
let treeSource = $derived(
treeLazyMode
- ? treeOwnerItems.filter((x) =>
- filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
+ ? [...treeOwnerItems, ...agents.map((x) => ({ ...x, type: 'agent' as 'agent' }))].filter(
+ (x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
)
: items
)
@@ -1662,7 +1772,8 @@
homeSelection.active &&
selectedIndex >= 0 &&
selectedIndex < displayedItems.length &&
- displayedItems[selectedIndex].type !== 'raw_app'
+ displayedItems[selectedIndex].type !== 'raw_app' &&
+ displayedItems[selectedIndex].type !== 'agent'
) {
e.preventDefault()
homeSelection.toggle(
@@ -1798,6 +1909,14 @@
size="md"
{item}
/>
+
{/snippet}
diff --git a/frontend/src/lib/components/home/treeViewUtils.ts b/frontend/src/lib/components/home/treeViewUtils.ts
index 2196d19409..bb134bb7eb 100644
--- a/frontend/src/lib/components/home/treeViewUtils.ts
+++ b/frontend/src/lib/components/home/treeViewUtils.ts
@@ -1,5 +1,5 @@
-import type { ListableApp, Script, Flow, ListableRawApp } from '$lib/gen'
-type TableItem = T & {
+import type { ListableApp, Script, Flow, ListableRawApp, ListableResource } from '$lib/gen'
+type TableItem = T & {
canWrite: boolean
marked?: string
type?: U
@@ -14,8 +14,11 @@ type TableScript = TableItem