Skip to content
Merged
42 changes: 35 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ agentcore # interactive TUI
├── gateway # inspect AgentCore Gateways
│ ├── get # get a Gateway by id
│ ├── list # list Gateways (server-side paginated)
│ ├── invoke # send a headless request through a Gateway
│ ├── invoke # invoke a Gateway headlessly or in a persistent console
│ ├── target
│ │ ├── get # get a Target under a Gateway
│ │ └── list # list Targets under a Gateway
Expand Down Expand Up @@ -161,6 +161,7 @@ agentcore memory record list --memory <memoryId> --namespace <namespace> --max-r
agentcore gateway get --id <gatewayId>
agentcore gateway list --max-results 20
agentcore gateway invoke --id <gatewayId> --payload file://request.json
agentcore gateway invoke --id <gatewayId> # open the persistent JSON console
agentcore gateway target get --gateway-id <gatewayId> --target-id <targetId>
agentcore gateway target list --gateway-id <gatewayId> --max-results 20
agentcore gateway connector get --gateway-id <gatewayId> --id <targetId>
Expand Down Expand Up @@ -216,9 +217,10 @@ Source-aware values: any field flag documented as such accepts the value inline,

### Invoke a Gateway

Gateway Invoke is a headless, project-independent HTTP request command. It gets
the Gateway by ID, uses the returned HTTPS origin, selects authentication from
the Gateway's authorizer, and preserves the request and response bodies.
Gateway Invoke is a project-independent HTTP request command with headless and
interactive modes. It gets the Gateway by ID, uses the returned HTTPS origin,
selects authentication from the Gateway's authorizer, and preserves the request
and response bodies.

```bash
# MCP Gateway: use the exact gatewayUrl returned by GetGateway.
Expand Down Expand Up @@ -268,9 +270,35 @@ stderr in raw and file modes. Redirects are returned without being followed.
Non-2xx response bodies use the selected output mode before the command exits
with a failure status.

Gateway Invoke V1 has no TUI, required request-type selector, tool/model
discovery command, or protocol-specific payload builder. Callers provide the
Gateway-relative route and protocol payload directly.
Without `--payload`, Gateway Invoke opens a persistent POST JSON console. Bare
invoke opens the Gateway picker, while `--id` opens the selected Gateway
directly. `--path`, `--session-id`, MCP session flags, `--header`, and
`--bearer-token` seed the console. Interactive bearer tokens may be inline or
`file://` sources, but not stdin. Explicit headless-only flags such as
`--method`, `--accept`, `--content-type`, `--output-file`, or `--json` keep the
command headless.

The console generates and displays a Runtime session ID, adopts returned Runtime
and MCP sessions, and streams textual responses as they arrive. An empty path
uses the exact `gatewayUrl`; `Ctrl+P` edits the raw Gateway-relative path and
`Ctrl+T` switches Gateways. Switching Gateways clears request context, while
changing paths preserves the draft and Gateway authentication but starts fresh
sessions.

| Shortcut | Action |
| ------------- | -------------------------------------------- |
| `Enter` | Send the JSON request |
| `Shift+Enter` | Insert a newline |
| `Ctrl+P` | Edit the Gateway-relative path |
| `Ctrl+T` | Change Gateway |
| `Ctrl+V` | Toggle raw and pretty completed JSON |
| `Esc` | Interrupt an active request or navigate back |
| `↑`/`↓` | Scroll response history |

Gateway Invoke V1 has no request-type selector, target/path discovery,
tool/model discovery command, authentication editor, or protocol-specific
payload builder. Callers provide the Gateway-relative route and protocol payload
directly. GET and DELETE remain available through headless invoke.

### Invoke a Runtime

Expand Down
4 changes: 3 additions & 1 deletion src/components/GatewayPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface GatewayPickerProps extends ScreenProps {
breadcrumb: string[];
description?: string;
onSelect: (gatewayId: string) => void;
onEscape?: () => void;
}

export function GatewayPicker({
Expand All @@ -51,6 +52,7 @@ export function GatewayPicker({
breadcrumb,
description,
onSelect,
onEscape,
}: GatewayPickerProps) {
const opts = coreOptsFromCtx(ctx);
const navigate = useNavigate();
Expand All @@ -71,7 +73,7 @@ export function GatewayPicker({
columns={gatewayColumns}
getValue={(row) => row.gatewayId}
onSelect={onSelect}
onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))}
onBack={onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/")))}
loadingMessage="Loading Gateways…"
errorMessage={(error) => `Error: ${error.message}`}
emptyMessage="No Gateways found in this Region."
Expand Down
126 changes: 126 additions & 0 deletions src/components/MultilineInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useState } from "react";
import cliTruncate from "cli-truncate";
import { Box, Text, useInput, useWindowSize } from "ink";
import { darkTheme } from "./ui/_core.js";

const theme = darkTheme;
const PREVIEW_LINES = 4;

export interface MultilineInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
submitDisabled?: boolean;
focus?: boolean;
}

function Cursor({ character }: { character: string }) {
return (
<Text color={theme.colors.focus} inverse>
{character}
</Text>
);
}

export function MultilineInput({
value,
onChange,
onSubmit,
placeholder = "Enter text",
submitDisabled = false,
focus = true,
}: MultilineInputProps) {
const { columns } = useWindowSize();
const [rawCursor, setRawCursor] = useState(value.length);
const cursor = Math.min(rawCursor, value.length);

useInput(
(input, key) => {
if (key.leftArrow) {
setRawCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setRawCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.upArrow || key.downArrow) return;

if (key.backspace || key.delete) {
if (cursor === 0) return;
onChange(value.slice(0, cursor - 1) + value.slice(cursor));
setRawCursor(cursor - 1);
return;
}

if (key.return) {
if (key.shift || key.meta) {
onChange(value.slice(0, cursor) + "\n" + value.slice(cursor));
setRawCursor(cursor + 1);
} else if (!submitDisabled) {
onSubmit();
}
return;
}
if (key.ctrl || key.meta || key.escape || input === "") return;

const next = input.replace(/\r/g, "\n");
onChange(value.slice(0, cursor) + next + value.slice(cursor));
setRawCursor(cursor + next.length);
},
{ isActive: focus },
);

if (value === "") {
return (
<Box>
<Cursor character={placeholder[0] ?? " "} />
<Text color={theme.colors.muted}>{placeholder.slice(1)}</Text>
</Box>
);
}

const lines = value.split("\n");
const beforeCursor = value.slice(0, cursor);
const cursorLine = beforeCursor.split("\n").length - 1;
const lastNewline = beforeCursor.lastIndexOf("\n");
const cursorColumn = cursor - lastNewline - 1;
const start = Math.max(0, cursorLine - PREVIEW_LINES + 1);
const visible = lines.slice(start, start + PREVIEW_LINES);

return (
<Box flexDirection="column">
{visible.map((line, index) => {
const lineIndex = start + index;
const prefix = index === 0 && start > 0 ? "… " : "";
if (lineIndex !== cursorLine) {
return (
<Box key={lineIndex} width={columns}>
<Text wrap="truncate-end">{cliTruncate(`${prefix}${line || " "}`, columns)}</Text>
</Box>
);
}

const horizontalMarker = cursorColumn >= columns - prefix.length ? "… " : "";
const available = Math.max(1, columns - prefix.length - horizontalMarker.length);
const offset = Math.max(0, cursorColumn - available + 1);
const before = line.slice(offset, cursorColumn);
const at = line[cursorColumn] ?? " ";
const after = line.slice(
cursorColumn + 1,
cursorColumn + 1 + Math.max(0, available - before.length - 1),
);
return (
<Box key={lineIndex} width={columns}>
<Text color={theme.colors.border}>{prefix}</Text>
<Text color={theme.colors.border}>{horizontalMarker}</Text>
{before ? <Text>{before}</Text> : null}
<Cursor character={at} />
{after ? <Text>{after}</Text> : null}
</Box>
);
})}
</Box>
);
}
9 changes: 9 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/scr
import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx";
import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx";
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
import type { Context } from "../router";

Expand Down Expand Up @@ -366,6 +367,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/gateway/get/:gatewayId/json"
element={<GatewayGetJsonScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/invoke/:gatewayId"
element={<GatewayInvokeScreen ctx={ctx} core={core} />}
/>
<Route
path="agentcore/gateway/target"
element={<GatewayTargetScreen ctx={ctx} core={core} />}
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/gateway.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ describe("Gateway menu and list", () => {

await waitForText(screen.lastFrame, "inspect AgentCore Gateways");
const frame = screen.lastFrame()!;
for (const command of ["get", "list", "target", "connector", "rule"]) {
for (const command of ["get", "list", "invoke", "target", "connector", "rule"]) {
expect(frame).toContain(command);
}
expect(frame).not.toMatch(/\bcreate\b/);
Expand Down
2 changes: 1 addition & 1 deletion src/handlers/gateway/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function createGatewayHandler(core: Core, io: AppIO): Router {
return new Router("gateway", "inspect AgentCore Gateways")
.use(withTuiOnEmptyFlagsAndArgs(core, io))
.default(renderTui(core, io))
.supportedTuiCommands("get", "list", "target", "connector", "rule")
.supportedTuiCommands("get", "list", "invoke", "target", "connector", "rule")
.handler(createCreateGatewayHandler(core, io))
.handler(createUpdateGatewayHandler(core, io))
.handler(createGetGatewayHandler(core))
Expand Down
52 changes: 49 additions & 3 deletions src/handlers/gateway/invoke/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import {
GatewayInvokeResponseError,
InputValidationError,
} from "../../../errors";
import type { AppIO } from "../../../io";
import { SourceResolver, type AppIO } from "../../../io";
import { ExitCode } from "../../../runnable";
import { createHandler, flag } from "../../../router";
import { createHandler, flag, PathKey } from "../../../router";
import { renderTuiAt } from "../../../tui";
import { JsonKey } from "../../keys";
import type { Core } from "../../types";
import { coreOptsFromCtx } from "../../utils";
Expand All @@ -16,10 +17,16 @@ import {
normalizeGatewayInvokeRequest,
parseGatewayInvokeHeaders,
resolveGatewayInvokeSources,
resolveGatewayInvokeTuiBearerToken,
} from "./request";
import { writeGatewayInvokeResponse } from "./response";
import { GatewayInvokeLaunchContextKey } from "./launchContext";

export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
export const createInvokeGatewayHandler = (
core: Core,
io: AppIO,
renderInvokeTui: typeof renderTuiAt = renderTuiAt,
) =>
createHandler({
name: "invoke",
description: "invoke an AgentCore Gateway",
Expand Down Expand Up @@ -58,6 +65,45 @@ export const createInvokeGatewayHandler = (core: Core, io: AppIO) =>
}

const jsonOutput = ctx.require(JsonKey);
if (flags.payload === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this break the pattern of tui only no flags? If so, could this be confusing for customers?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but Runtime invoke already follows this pattern where a missing --payload launches the TUI. I think it technically could be a little bit confusing, but I personally think there's are a couple good reasons for why we should keep it this way. For one, we wanted to keep the TUI simple to start. When building runtime invoke I built an advanced options menu, which was accessible in the invoke TUI via a ctrl + key command. This added complexity to the code and we thought it would be better to start simple and layer this complexity on top in the future. So to keep these configurations accessible, I opted to not open the TUI on no --payload. Second, I think for things like --session-id it is more intuitive to pass it in via the CLI before opening the agent than after opening up the agent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The harness invoke TUI is also not strictly no-flags only so I think it's okay to break the pattern on invoke for now.

I think it'd be worthwhile though to have a conversation about this with the team though.

const hasHeadlessOnlyFlag = Object.entries(flags).some(
([name, value]) =>
![
"id",
"path",
"payload",
"header",
"bearer-token",
"session-id",
"mcp-session-id",
"mcp-protocol-version",
].includes(name) && value !== undefined,
);
if (!jsonOutput && !hasHeadlessOnlyFlag) {
const applicationHeaders = parseGatewayInvokeHeaders(flags.header);
const bearerToken = await resolveGatewayInvokeTuiBearerToken(
flags["bearer-token"],
new SourceResolver({ stdin: io.stdin }),
);
const launchContext = {
gatewayId: flags.id,
path: flags.path,
runtimeSessionId: flags["session-id"],
mcpSessionId: flags["mcp-session-id"],
mcpProtocolVersion: flags["mcp-protocol-version"],
applicationHeaders,
bearerToken,
};
await renderInvokeTui(
`${ctx.require(PathKey)}/${encodeURIComponent(flags.id)}`,
ctx.withValue(GatewayInvokeLaunchContextKey, launchContext),
core,
io,
);
return;
}
}

if (jsonOutput && flags["output-file"] !== undefined) {
throw new InputValidationError("--json cannot be used with --output-file");
}
Expand Down
Loading
Loading