-
Enter manually
+
+ {isLocalAcp ? "Local ACP command" : "Enter manually"}
+
- Enter an official registry ID and any local executable or auth override.
+ {isLocalAcp
+ ? `Run an installed ACP executable on ${environmentLabel}.`
+ : "Enter an official registry ID and any local executable or auth override."}
+ {isLocalAcp ? (
+
+ ) : null}
{isAcpRegistry && hasAttemptedSubmit && acpSelectionError ? (
{acpSelectionError}
@@ -470,6 +520,7 @@ export function AddProviderInstanceDialog({
providerInstances={settings.providerInstances}
onPrepared={handleAcpPrepared}
onManualConfiguration={handleManualAcpConfiguration}
+ onLocalConfiguration={handleLocalAcpConfiguration}
onLoadingChange={setIsRegistryLoading}
onPreparingChange={setIsPreparingRegistryAgent}
/>
@@ -644,7 +695,11 @@ export function AddProviderInstanceDialog({
) : (
)}
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx
index 415ebb97c029..9965925ad27e 100644
--- a/apps/web/src/components/settings/ProviderInstanceCard.tsx
+++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx
@@ -296,7 +296,7 @@ function ProviderEnvironmentFieldRow(props: {
);
}
-function ProviderEnvironmentSection(props: {
+export function ProviderEnvironmentSection(props: {
readonly environment: ReadonlyArray
;
readonly onChange: (environment: ReadonlyArray) => void;
}) {
@@ -717,8 +717,16 @@ export function ProviderInstanceCard({
driverKind={driverKind ?? instance.driver}
displayName={displayName}
accentColor={accentColor}
- acpRegistryAgentId={readConfigString(instance.config, "agentId") ?? undefined}
- acpRegistryIconUrl={readConfigString(instance.config, "registryIconUrl") ?? undefined}
+ acpRegistryAgentId={
+ readConfigString(instance.config, "source") === "local"
+ ? undefined
+ : (readConfigString(instance.config, "agentId") ?? undefined)
+ }
+ acpRegistryIconUrl={
+ readConfigString(instance.config, "source") === "local"
+ ? undefined
+ : (readConfigString(instance.config, "registryIconUrl") ?? undefined)
+ }
showBadge={Boolean(accentColor)}
className="size-5"
iconClassName="size-4 text-foreground/80"
diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts
index 5737e47f2a13..6edeedb30740 100644
--- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts
+++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts
@@ -57,12 +57,20 @@ describe("ProviderSettingsForm helpers", () => {
expect(acpRegistry).toBeDefined();
expect(acpRegistry?.hasDefaultInstance).toBe(false);
expect(deriveProviderSettingsFields(acpRegistry!).map((field) => field.key)).toEqual([
+ "source",
"agentId",
"commandPath",
"authMethodId",
]);
});
+ it("shows the local executable without registry identity or authentication fields", () => {
+ const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")];
+ expect(
+ deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map((field) => field.key),
+ ).toEqual(["source", "commandPath"]);
+ });
+
it("derives a select control with its choices for the Antigravity sign-in method", () => {
const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")];
expect(antigravity).toBeDefined();
diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx
index 94372b97c868..dfb35facbd60 100644
--- a/apps/web/src/components/settings/ProviderSettingsForm.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo, type ReactNode } from "react";
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import type {
@@ -9,8 +9,10 @@ import type {
ProviderSettingsFormOption,
ProviderSettingsFormSchemaAnnotation,
} from "@t3tools/contracts";
+import { PlusIcon, XIcon } from "lucide-react";
import { cn } from "../../lib/utils";
+import { Button } from "../ui/button";
import { DraftInput } from "../ui/draft-input";
import { Input } from "../ui/input";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
@@ -76,7 +78,10 @@ function readFieldBooleanDefault(
export function deriveProviderSettingsFields(
definition: ProviderClientDefinition,
+ value?: unknown,
): ReadonlyArray {
+ const isLocalAcp =
+ definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local";
const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition);
const orderedKeys = new Map(
(schemaAnnotation.order ?? []).map((key, index) => [key, index] as const),
@@ -95,6 +100,7 @@ export function deriveProviderSettingsFields(
const fieldSchema = definition.settingsSchema.fields[key]!;
const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema);
if (formAnnotation.hidden) return [];
+ if (isLocalAcp && key !== "source" && key !== "commandPath") return [];
const annotatedTitle = readFieldAnnotationString(fieldSchema, "title");
const annotatedDescription = readFieldAnnotationString(fieldSchema, "description");
@@ -102,11 +108,20 @@ export function deriveProviderSettingsFields(
{
key,
control: formAnnotation.control ?? "text",
- label: annotatedTitle ?? titleizeFieldKey(key),
- ...(annotatedDescription !== undefined ? { description: annotatedDescription } : {}),
- ...(formAnnotation.placeholder !== undefined
- ? { placeholder: formAnnotation.placeholder }
- : {}),
+ label:
+ isLocalAcp && key === "commandPath"
+ ? "Executable"
+ : (annotatedTitle ?? titleizeFieldKey(key)),
+ ...(isLocalAcp && key === "commandPath"
+ ? { description: "Executable name or path on this environment." }
+ : annotatedDescription !== undefined
+ ? { description: annotatedDescription }
+ : {}),
+ ...(isLocalAcp && key === "commandPath"
+ ? { placeholder: "e.g. dsh" }
+ : formAnnotation.placeholder !== undefined
+ ? { placeholder: formAnnotation.placeholder }
+ : {}),
clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",
...(formAnnotation.control === "switch"
? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
@@ -119,6 +134,112 @@ export function deriveProviderSettingsFields(
});
}
+let commandArgumentDraftId = 0;
+const makeCommandArgumentDraftRow = (value: string) => ({
+ id: `provider-argument-${commandArgumentDraftId++}`,
+ value,
+});
+
+function commandArgumentsEqual(left: ReadonlyArray, right: ReadonlyArray) {
+ return left.length === right.length && left.every((argument, index) => argument === right[index]);
+}
+
+function ProviderCommandArguments({
+ value,
+ onChange,
+}: Pick) {
+ const args = useMemo(() => {
+ const configured =
+ value !== null && typeof value === "object"
+ ? (value as Record).commandArgs
+ : undefined;
+ return Array.isArray(configured)
+ ? configured.filter((argument): argument is string => typeof argument === "string")
+ : [];
+ }, [value]);
+ const [rows, setRows] = useState(() => args.map(makeCommandArgumentDraftRow));
+ const rowsRef = useRef(rows);
+ const previousArgsRef = useRef(args);
+ const lastPublishedArgsRef = useRef | undefined>(undefined);
+
+ useEffect(() => {
+ const previousArgs = previousArgsRef.current;
+ const lastPublishedArgs = lastPublishedArgsRef.current;
+ previousArgsRef.current = args;
+ lastPublishedArgsRef.current = undefined;
+ if (
+ commandArgumentsEqual(previousArgs, args) ||
+ (lastPublishedArgs !== undefined && commandArgumentsEqual(lastPublishedArgs, args))
+ )
+ return;
+ const nextRows = args.map(makeCommandArgumentDraftRow);
+ rowsRef.current = nextRows;
+ setRows(nextRows);
+ }, [args]);
+
+ const updateArguments = (nextRows: typeof rows) => {
+ rowsRef.current = nextRows;
+ setRows(nextRows);
+ const next = nextRows.map((row) => row.value);
+ lastPublishedArgsRef.current = next;
+ const config =
+ value !== null && typeof value === "object" ? { ...(value as Record) } : {};
+ onChange({ ...config, commandArgs: next });
+ };
+
+ return (
+ updateArguments([...rowsRef.current, makeCommandArgumentDraftRow("")])}
+ >
+
+ Add argument
+
+ }
+ >
+ {rows.length > 0 ? (
+
+ {rows.map((argument, index) => (
+
+
+ updateArguments(
+ rowsRef.current.map((current) =>
+ current.id === argument.id ? { ...current, value: next } : current,
+ ),
+ )
+ }
+ aria-label={`Argument ${index + 1}`}
+ spellCheck={false}
+ />
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
function readProviderConfigString(config: unknown, key: string): string {
if (config === null || typeof config !== "object") return "";
const value = (config as Record)[key];
@@ -411,7 +532,12 @@ export function ProviderSettingsForm({
variant,
onChange,
}: ProviderSettingsFormProps) {
- const fields = useMemo(() => deriveProviderSettingsFields(definition), [definition]);
+ const fields = useMemo(
+ () => deriveProviderSettingsFields(definition, value),
+ [definition, value],
+ );
+ const isLocalAcp =
+ definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local";
if (fields.length === 0) {
return null;
@@ -429,6 +555,7 @@ export function ProviderSettingsForm({
onChange={onChange}
/>
))}
+ {isLocalAcp ? : null}
>
);
}
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
index 41fd9efb5da4..cc8fe347c124 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
@@ -922,6 +922,7 @@ export function EnvironmentProviderSettings({
}
if (row.driver !== ProviderDriverKind.make("acpRegistry")) return;
+ if (providerConfigString(row.instance.config, "source") === "local") return;
const agentId = providerConfigString(row.instance.config, "agentId");
if (agentId === null) return;
diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts
index a5bb26e691c0..4ff23880ade6 100644
--- a/apps/web/src/providerInstances.test.ts
+++ b/apps/web/src/providerInstances.test.ts
@@ -619,6 +619,45 @@ describe("resolveDefaultProviderModelSelection", () => {
});
describe("provider icon metadata", () => {
+ it("clears stale registry branding when an instance switches to a local command", () => {
+ const instanceId = ProviderInstanceId.make("custom-acp");
+ const driver = ProviderDriverKind.make("acpRegistry");
+ const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg";
+ const snapshots = deriveProviderInstanceEntries([
+ { ...provider({ provider: driver, instanceId }), iconUrl },
+ ]);
+ const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl };
+ const brandedEntries = applyProviderInstanceSettings(snapshots, {
+ providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
+ providers: {} as never,
+ });
+ expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent");
+ expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl);
+
+ const [localEntry] = applyProviderInstanceSettings(brandedEntries, {
+ providerInstances: {
+ [instanceId]: {
+ driver,
+ enabled: false,
+ config: { ...registryConfig, source: "local", commandPath: "dsh" },
+ },
+ },
+ providers: {} as never,
+ });
+ expect(localEntry?.acpRegistryAgentId).toBeUndefined();
+ expect(localEntry?.acpRegistryIconUrl).toBeUndefined();
+ expect(localEntry?.enabled).toBe(false);
+ expect(localEntry?.snapshot).toBe(snapshots[0]?.snapshot);
+
+ const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], {
+ providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } },
+ providers: {} as never,
+ });
+ expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent");
+ expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl);
+ expect(restoredEntry?.enabled).toBe(true);
+ });
+
it("retains server-published registry icons without local settings", () => {
const iconUrl = "https://cdn.agentclientprotocol.com/registry/icons/swe-agent.svg";
const [entry] = deriveProviderInstanceEntries([
diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts
index ae2ea5699bf5..60630c5f826b 100644
--- a/apps/web/src/providerInstances.ts
+++ b/apps/web/src/providerInstances.ts
@@ -210,6 +210,9 @@ export function applyProviderInstanceSettings(
explicitInstance.config !== null && typeof explicitInstance.config === "object"
? (explicitInstance.config as Readonly>)
: null;
+ if (config?.source === "local") {
+ return { ...entry, enabled, acpRegistryAgentId: undefined, acpRegistryIconUrl: undefined };
+ }
const agentId = config?.agentId;
const iconUrl = config?.registryIconUrl;
return {
diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md
index adf4e17af631..9f179e88da0f 100644
--- a/docs/user/providers-acp.md
+++ b/docs/user/providers-acp.md
@@ -1,6 +1,6 @@
-# ACP Registry
+# ACP providers
-T3 Code can run coding agents from the official
+T3 Code can run installed ACP commands or coding agents from the official
[ACP Registry](https://agentclientprotocol.com/get-started/registry). Registry agents bring their
own models, tools, and sign-in, while T3 Code provides projects, threads, checkpoints, and task
delegation.
@@ -23,6 +23,17 @@ work through the same generic integration.
Search only shows agents that can run on the connected server. Registry agents are third-party
code; review an agent's source and license before adding it.
+## Add a local command
+
+In **Settings → Providers → Add provider**, select **Local ACP command**. Enter the executable
+name or path on the selected environment, add one literal argument per row, and choose a display
+name. For DeepSeek Harness, use `dsh` with arguments `--profile` and `acp`.
+
+Local commands require no registry entry or download. Environment overrides apply to the command;
+credentials and skills remain managed by the installed agent or its wrapper. Models and reasoning
+options come from ACP, including model IDs that contain JSON. T3 Code launches the executable directly
+without expanding shell expressions.
+
## Where agents run
Registry agents always run on the machine that hosts your T3 Code server. That stays true when you
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 082459efc3df..c2720d0a371f 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -871,6 +871,19 @@ export type AcpRegistryDistributionPreference = typeof AcpRegistryDistributionPr
export const AcpRegistrySettings = makeProviderSettingsSchema(
{
+ source: Schema.Literals(["registry", "local"]).pipe(
+ Schema.withDecodingDefault(Effect.succeed("registry")),
+ Schema.annotateKey({
+ title: "ACP source",
+ providerSettingsForm: {
+ control: "select",
+ options: [
+ { value: "registry", label: "ACP Registry" },
+ { value: "local", label: "Local command" },
+ ],
+ },
+ }),
+ ),
enabled: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(true)),
Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
@@ -888,10 +901,14 @@ export const AcpRegistrySettings = makeProviderSettingsSchema(
Schema.annotateKey({
title: "Executable override",
description:
- "Optional local executable to use instead of installing the registry distribution. Registry arguments and environment are still applied.",
+ "Executable on this environment. For registry agents, this overrides the distribution executable while keeping its arguments and environment.",
providerSettingsForm: { placeholder: "Registry default", clearWhenEmpty: "omit" },
}),
),
+ commandArgs: Schema.Array(Schema.String).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
authMethodId: TrimmedString.pipe(
Schema.withDecodingDefault(Effect.succeed("")),
Schema.annotateKey({
@@ -911,7 +928,7 @@ export const AcpRegistrySettings = makeProviderSettingsSchema(
),
},
{
- order: ["agentId", "commandPath", "authMethodId"],
+ order: ["source", "agentId", "commandPath", "authMethodId"],
},
);
export type AcpRegistrySettings = typeof AcpRegistrySettings.Type;