Hello again. The previous lesson established the physical connection: your Node.js host attached to the authorized fixture, loaded a bundled agent, received an unstructured readiness object, then unloaded and detached cleanly.
This begins the protocol portion of the Frida Host–Agent Foundations module. A raw send({ ... }) payload is useful for a smoke test, but it is not yet a dependable application boundary. In this lesson, you will define a small versioned contract, validate every message crossing the boundary, and exercise communication in both directions using a ping and pong.
By the end, a malformed payload, an unexpected Frida transport message, and an agent runtime exception will be distinguishable outcomes—not values silently accepted as application events.
Two layers: Frida transport and your application protocol
Frida already gives you a messaging transport:
- The agent calls
send(payload). - The host receives a Frida envelope whose
typeis"send"and whosepayloadis your value. - The host calls
script.post(...). - The agent receives that post through
recv(...).
That transport is not itself your application protocol. In particular, Frida’s type: "send" only says that the agent used send; it says nothing about what your CLI can safely do with message.payload.
Messages | Frida • A world-class dynamic instrumentation toolkit
Read Frida’s “Messages” guide to separate its built-in message envelope from the payload your application defines. It also explains the receive behavior that matters for a long-running agent.
In “Sending messages from a target process,” read from the sending explanation. Notice that the host receives a wrapper containing type and payload, rather than the payload alone. Then go to “Receiving messages in a target process” and read the recv mechanics. The callback handles exactly one incoming message, so an agent that must keep receiving commands has to register recv() again.
We will keep the two layers explicit:
| Layer | Example | Owner |
|---|---|---|
| Frida transport envelope | { type: "send", payload: ... } | Frida |
| Host command channel | { type: "grasp.host.v1", payload: ... } | Your adapter |
| Application protocol payload | { protocolVersion: 1, kind: "host.ping", ... } | Your protocol contract |
The outer Frida type helps route a message through Frida. The inner kind identifies a meaningful application-level message. Do not conflate them.
At this point, the payloads should remain deliberately small and JSON-safe: plain objects, strings, booleans, finite numbers, and arrays of those values. Do not send Frida-specific values such as NativePointer instances, functions, or native handles. Later, trace events will convert those target-local values into explicit serializable representations.
Treat the protocol as a narrow shared contract
Your host and agent are built separately and run in separate runtimes. Even if both currently import the same TypeScript source, a stale agent bundle, a future plugin, a serialization mistake, or an agent bug can still produce values that violate its declared TypeScript type.
That means:
TypeScript describes what the code intends to exchange. Runtime validation decides what the receiving runtime is willing to accept.
Create a deliberately narrow workspace package or top-level package directory for the contract:
packages/
frida-protocol/
src/
messages.ts
agent/
src/
index.ts
src/
infrastructure/
frida/
decode-agent-message.ts
Name it something specific such as @grasp/frida-protocol. This is not a generic “shared utilities” folder. It has one purpose: to define the compatibility contract across the host–agent boundary. It must not import Node.js APIs, Frida APIs, CLI rendering code, filesystem adapters, or domain services.
Zod is appropriate here because it gives one definition both a runtime validator and an inferred static type.
Read Zod’s basic guide as a focused refresher on defining runtime schemas, obtaining non-throwing parse results, and deriving TypeScript types from the schema.
In “Defining a schema” and “Parsing data,” read from schema parsing. For a host receiving agent data, the incoming value begins as unknown, regardless of its declared TypeScript type. In “Handling errors,” focus on safe parsing. We will use the resulting discriminated union so invalid agent traffic becomes an explicit result. Finally, in “Inferring types,” read type inference. The protocol types below are derived from the schemas rather than maintained as separate interfaces.
Define protocol version 1
Start with three agent-originated messages:
agent.readyconfirms that the agent loaded into the expected process.agent.pongacknowledges a host command and correlates it using a request ID.agent.diagnosticreports that the agent rejected a malformed host command.
For now, define one host-originated command:
host.ping, which carries a request ID and nonce.
Create packages/frida-protocol/src/messages.ts:
import { z } from "zod";
export const PROTOCOL_VERSION = 1 as const;
export const HOST_MESSAGE_CHANNEL = "grasp.host.v1" as const;
const RequestIdSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9_-]+$/);
const NonceSchema = z.string().min(1).max(128);
const PositivePidSchema = z.number().int().positive();
export const HostToAgentMessageSchema = z.discriminatedUnion("kind", [
z
.object({
protocolVersion: z.literal(PROTOCOL_VERSION),
kind: z.literal("host.ping"),
requestId: RequestIdSchema,
nonce: NonceSchema,
})
.strict(),
]);
export const AgentToHostMessageSchema = z.discriminatedUnion("kind", [
z
.object({
protocolVersion: z.literal(PROTOCOL_VERSION),
kind: z.literal("agent.ready"),
pid: PositivePidSchema,
agentVersion: z.string().min(1).max(64),
})
.strict(),
z
.object({
protocolVersion: z.literal(PROTOCOL_VERSION),
kind: z.literal("agent.pong"),
requestId: RequestIdSchema,
nonce: NonceSchema,
pid: PositivePidSchema,
})
.strict(),
z
.object({
protocolVersion: z.literal(PROTOCOL_VERSION),
kind: z.literal("agent.diagnostic"),
code: z.literal("invalid-host-message"),
message: z.string().min(1).max(256),
})
.strict(),
]);
export type HostToAgentMessage = Readonly<
z.infer<typeof HostToAgentMessageSchema>
>;
export type AgentToHostMessage = Readonly<
z.infer<typeof AgentToHostMessageSchema>
>;
Several choices are intentional.
A literal version makes incompatibility visible
Every message contains:
protocolVersion: z.literal(PROTOCOL_VERSION)
A host that understands version 1 must reject version 2 rather than accidentally interpreting it as version 1. This is particularly important because the injected bundle can outlive the host code that created it during development.
When you eventually make an incompatible change, do not quietly change the meaning of an existing field. Introduce a new versioned schema and decide explicitly whether the host supports both versions during a migration.
kind is the discriminant
The kind property creates a discriminated union. After validation, TypeScript can narrow the message safely:
function renderAgentMessage(message: AgentToHostMessage): string {
switch (message.kind) {
case "agent.ready":
return `Agent loaded in PID ${message.pid}.`;
case "agent.pong":
return `Agent acknowledged ${message.requestId}.`;
case "agent.diagnostic":
return `Agent diagnostic: ${message.code}.`;
}
}
Avoid a vague schema such as:
{
kind: z.string(),
payload: z.unknown(),
}
It shifts all meaningful validation elsewhere and leaves each receiver to rediscover the contract. A discriminated union lets the protocol grow in named, reviewable cases—such as a future trace.call message—without turning every handler into a chain of casts.
Strict objects are an intentional compatibility policy
The .strict() calls reject extra fields. For a small internal protocol, this is useful: a typo such as requestID instead of requestId fails immediately instead of being silently ignored.
Strictness does mean that adding a field is a protocol change. That is appropriate here because host and agent are released together as part of the same local CLI distribution. If you later support independently versioned plugins, you may choose a more nuanced extension mechanism, but do not add an unbounded metadata: Record<string, unknown> merely to avoid making a versioning decision.
Request IDs correlate asynchronous replies
The host sends a requestId; the agent returns it in agent.pong. The nonce demonstrates that a reply carries the values associated with that specific request.
This is more reliable than “the next message must be the reply.” Once hooks and Windows API traces are active, asynchronous trace messages can arrive between a command and its response.
Validate Frida’s outer envelope before the payload
Your host does not receive an AgentToHostMessage. It receives a Frida-provided value. Treat it as unknown, then validate:
- Is it an object with a string
type? - Is its Frida transport
type"send"? - Does it have a
payloadproperty? - Does that payload satisfy
AgentToHostMessageSchema?
An agent runtime exception is a separate transport outcome, represented by Frida as type: "error". It is not an agent.diagnostic message, because an uncaught exception prevented the agent from deliberately sending a valid protocol payload.
Create src/infrastructure/frida/decode-agent-message.ts:
import { z } from "zod";
import {
AgentToHostMessageSchema,
type AgentToHostMessage,
} from "@grasp/frida-protocol";
export type ProtocolViolation =
| {
readonly kind: "invalid-frida-envelope";
readonly detail: string;
}
| {
readonly kind: "unexpected-frida-message";
readonly receivedType: string;
}
| {
readonly kind: "agent-runtime-error";
readonly detail: string;
}
| {
readonly kind: "invalid-agent-payload";
readonly issues: readonly string[];
};
export type DecodeAgentMessageResult =
| {
readonly ok: true;
readonly value: AgentToHostMessage;
}
| {
readonly ok: false;
readonly error: ProtocolViolation;
};
const FridaEnvelopeSchema = z
.object({
type: z.string(),
})
.passthrough();
const FridaErrorEnvelopeSchema = z
.object({
type: z.literal("error"),
description: z.string().optional(),
})
.passthrough();
function hasPayload(value: unknown): value is { payload: unknown } {
return (
typeof value === "object" &&
value !== null &&
Object.hasOwn(value, "payload")
);
}
function summarizeIssues(error: z.ZodError): readonly string[] {
return error.issues.slice(0, 3).map((issue) => {
const location = issue.path.join(".") || "<root>";
return `${location}: ${issue.message}`;
});
}
export function decodeAgentMessage(
rawMessage: unknown,
): DecodeAgentMessageResult {
const envelope = FridaEnvelopeSchema.safeParse(rawMessage);
if (!envelope.success) {
return {
ok: false,
error: {
kind: "invalid-frida-envelope",
detail: "Expected a Frida message object with a string type.",
},
};
}
if (envelope.data.type === "error") {
const runtimeError = FridaErrorEnvelopeSchema.safeParse(rawMessage);
return {
ok: false,
error: {
kind: "agent-runtime-error",
detail: runtimeError.success
? runtimeError.data.description ?? "Agent threw an unknown error."
: "Agent threw an error with an invalid Frida error envelope.",
},
};
}
if (envelope.data.type !== "send") {
return {
ok: false,
error: {
kind: "unexpected-frida-message",
receivedType: envelope.data.type,
},
};
}
if (!hasPayload(rawMessage)) {
return {
ok: false,
error: {
kind: "invalid-frida-envelope",
detail: "Frida send message did not include a payload property.",
},
};
}
const payload = AgentToHostMessageSchema.safeParse(rawMessage.payload);
if (!payload.success) {
return {
ok: false,
error: {
kind: "invalid-agent-payload",
issues: summarizeIssues(payload.error),
},
};
}
return {
ok: true,
value: payload.data,
};
}
This is a transport adapter, not a domain service. It knows Frida’s envelope shape and translates it into either:
- a validated application protocol message, or
- a structured failure your session lifecycle or CLI layer can render consistently.
It deliberately does not call console.log, terminate the process, or decide whether an invalid message requires detachment. Those are policy decisions for higher layers. During startup, a malformed agent.ready should fail the attach operation. During a later tracing session, you may record the diagnostic, show it in the REPL, and keep the session alive if the failure is recoverable.
Make the agent validate host commands too
The host is not the only receiver. The agent’s recv() callback receives a Frida message object, so it must extract and validate the payload before treating it as a command.
Replace the minimal agent in agent/src/index.ts with the following:
import {
AgentToHostMessageSchema,
HOST_MESSAGE_CHANNEL,
HostToAgentMessageSchema,
PROTOCOL_VERSION,
type AgentToHostMessage,
} from "@grasp/frida-protocol";
function extractPayload(rawMessage: unknown): unknown {
if (
typeof rawMessage !== "object" ||
rawMessage === null ||
!Object.hasOwn(rawMessage, "payload")
) {
return undefined;
}
return rawMessage.payload;
}
function emit(message: AgentToHostMessage): void {
// This parse is useful during development: it verifies that the agent
// never emits a value outside the contract before Frida serializes it.
send(AgentToHostMessageSchema.parse(message));
}
function receiveNextCommand(): void {
recv(HOST_MESSAGE_CHANNEL, (rawMessage: unknown) => {
const command = HostToAgentMessageSchema.safeParse(
extractPayload(rawMessage),
);
if (!command.success) {
emit({
protocolVersion: PROTOCOL_VERSION,
kind: "agent.diagnostic",
code: "invalid-host-message",
message: "Rejected a host command that does not match protocol v1.",
});
receiveNextCommand();
return;
}
if (command.data.kind === "host.ping") {
emit({
protocolVersion: PROTOCOL_VERSION,
kind: "agent.pong",
requestId: command.data.requestId,
nonce: command.data.nonce,
pid: Process.id,
});
}
receiveNextCommand();
});
}
emit({
protocolVersion: PROTOCOL_VERSION,
kind: "agent.ready",
pid: Process.id,
agentVersion: "0.1.0",
});
receiveNextCommand();
The call to receiveNextCommand() at the end of the callback is required. Frida’s recv() registration handles one message, so an agent that should remain responsive must register the next receive after completing the current one.
The agent validates even messages produced by the host’s own TypeScript code. This may appear redundant, but it protects the agent from:
- a stale or manually modified host bundle;
- future code that bypasses the intended command builder;
- a mistaken Frida
script.post(...)call; - version drift between the CLI and bundled agent.
The diagnostic intentionally does not echo the malformed command back to the host. Echoing unknown input can create oversized logs or inadvertently retain sensitive values. A concise, typed diagnostic is enough for this version of the protocol.
Build checkpoint: run
pnpm run build:agentafter introducing the shared package import. Confirm that your agent bundler resolves@grasp/frida-protocoland includes its Zod dependency in the generated JavaScript bundle. The injected agent must not depend on Node.js at runtime.
Use validated messages in the fixture smoke runner
Update the smoke runner from the previous lesson so it no longer accepts every Frida "send" message as readiness. It should decode the transport envelope first, and resolve readiness only when it receives a valid agent.ready message whose PID matches the attached PID.
The essential listener setup looks like this:
import {
HOST_MESSAGE_CHANNEL,
HostToAgentMessageSchema,
type AgentToHostMessage,
} from "@grasp/frida-protocol";
import { decodeAgentMessage } from "../infrastructure/frida/decode-agent-message.js";
// Create these promises before script.load() so early messages cannot be lost.
let resolveReady!: () => void;
let rejectReady!: (error: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
let resolvePong!: (
message: Extract<AgentToHostMessage, { kind: "agent.pong" }>,
) => void;
let rejectPong!: (error: Error) => void;
const pong = new Promise<
Extract<AgentToHostMessage, { kind: "agent.pong" }>
>((resolve, reject) => {
resolvePong = resolve;
rejectPong = reject;
});
const expectedRequestId = "fixture-ping-1";
script.message.connect((rawMessage: unknown) => {
const decoded = decodeAgentMessage(rawMessage);
if (!decoded.ok) {
const error = new Error(
`Protocol failure: ${JSON.stringify(decoded.error)}`,
);
rejectReady(error);
rejectPong(error);
return;
}
const message = decoded.value;
switch (message.kind) {
case "agent.ready":
if (message.pid !== pid) {
rejectReady(
new Error(
`Agent reported PID ${message.pid}, but host attached to PID ${pid}.`,
),
);
return;
}
console.log(
`Agent ${message.agentVersion} is ready in PID ${message.pid}.`,
);
resolveReady();
return;
case "agent.pong":
if (message.requestId !== expectedRequestId) {
rejectPong(
new Error(`Received unexpected pong: ${message.requestId}`),
);
return;
}
resolvePong(message);
return;
case "agent.diagnostic":
rejectPong(
new Error(`Agent diagnostic ${message.code}: ${message.message}`),
);
}
});
await script.load();
await ready;
const ping = {
protocolVersion: 1,
kind: "host.ping",
requestId: expectedRequestId,
nonce: "fixture-protocol-check",
} as const;
const validatedPing = HostToAgentMessageSchema.parse(ping);
script.post({
type: HOST_MESSAGE_CHANNEL,
payload: validatedPing,
});
const receivedPong = await pong;
if (receivedPong.nonce !== ping.nonce) {
throw new Error("Agent pong nonce did not match the host ping.");
}
console.log(`Protocol round trip confirmed for PID ${receivedPong.pid}.`);
Keep the existing nested cleanup structure around script.unload() and session.detach(). The changes above only replace the permissive message handling and add a deterministic round trip.
A real session service will eventually own a message router rather than resolving fixed ready and pong promises. For this controlled fixture, the two promises make the protocol sequence observable without prematurely designing a general event bus.
Test the contract without attaching to a process
Most protocol behavior should be testable without Frida or Windows. The shared schemas and host decoder are pure TypeScript. Add tests such as src/infrastructure/frida/decode-agent-message.test.ts:
import { describe, expect, it } from "vitest";
import {
HostToAgentMessageSchema,
PROTOCOL_VERSION,
} from "@grasp/frida-protocol";
import { decodeAgentMessage } from "./decode-agent-message.js";
describe("decodeAgentMessage", () => {
it("accepts a valid agent-ready payload in a Frida send envelope", () => {
const result = decodeAgentMessage({
type: "send",
payload: {
protocolVersion: PROTOCOL_VERSION,
kind: "agent.ready",
pid: 4242,
agentVersion: "0.1.0",
},
});
expect(result).toEqual({
ok: true,
value: {
protocolVersion: 1,
kind: "agent.ready",
pid: 4242,
agentVersion: "0.1.0",
},
});
});
it("rejects an unsupported protocol version", () => {
const result = decodeAgentMessage({
type: "send",
payload: {
protocolVersion: 2,
kind: "agent.ready",
pid: 4242,
agentVersion: "0.1.0",
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.kind).toBe("invalid-agent-payload");
}
});
it("keeps Frida runtime errors distinct from protocol payload failures", () => {
const result = decodeAgentMessage({
type: "error",
description: "ReferenceError: missingName is not defined",
});
expect(result).toEqual({
ok: false,
error: {
kind: "agent-runtime-error",
detail: "ReferenceError: missingName is not defined",
},
});
});
});
describe("HostToAgentMessageSchema", () => {
it("rejects undeclared command properties", () => {
const result = HostToAgentMessageSchema.safeParse({
protocolVersion: 1,
kind: "host.ping",
requestId: "ping-1",
nonce: "known-value",
unexpected: true,
});
expect(result.success).toBe(false);
});
});
Run the unit tests:
pnpm vitest run
Then run the integration check with the authorized fixture from the previous lesson:
pnpm run build:agent
pnpm exec tsx src/dev/attach-fixture.ts <fixture-pid>
A successful run should report both facts:
Agent 0.1.0 is ready in PID <fixture-pid>.
Protocol round trip confirmed for PID <fixture-pid>.
The agent.ready result confirms agent-to-host validation. The agent.pong result confirms that the host posted a command, the agent validated it, and the agent returned a correlated reply.
Key takeaways
You now have a versioned, typed protocol rather than an ad hoc collection of Frida payloads:
- Frida’s transport envelope and your application payload are separate layers.
HostToAgentMessageSchemaandAgentToHostMessageSchemadefine a small, discriminated, JSON-safe protocol.z.inferkeeps TypeScript types tied to the runtime schema instead of duplicating interfaces.- Both host and agent validate incoming data, even when they share TypeScript source.
- The host translates malformed envelopes, invalid payloads, unexpected transport messages, and uncaught agent errors into distinct typed failures.
requestIdandnonceestablish the pattern for correlating commands with asynchronous responses.- The agent re-registers
recv()after each command so it remains responsive.
Next, you will expose a typed operation through Frida RPC and invoke it from the Node.js host. RPC will provide a convenient request–response interface for operations such as querying target-local information, while this message protocol remains the foundation for asynchronous trace events and diagnostics.
Can't find a good explanation? Sign up and we'll make it for you
Sign up