Welcome back. In the previous lesson, you made the asynchronous message boundary explicit: the host and agent validate a small versioned protocol, while Frida’s own transport envelope and runtime errors remain separate concerns.
Now we add a second communication style: Frida RPC. An RPC export lets the host ask the injected agent for a specific value and await a direct reply. That makes it a good fit for bounded request-response operations, such as inspecting the target runtime or resolving a module. It does not replace the validated send() protocol, which will remain the right channel for unsolicited trace events, diagnostics, and high-volume instrumentation output.
By the end of this lesson, your bundled agent will expose a typed getRuntimeInfo() operation, and the Node.js host will invoke it through a narrow adapter that validates the returned data before the rest of the application can use it.
RPC belongs beside messages, not in place of them
The host and agent still occupy separate runtimes and separate sides of the Frida boundary.

The distinction is architectural rather than merely syntactic:
| Need | Prefer | Why |
|---|---|---|
| “What runtime is this agent currently inside?” | RPC | One host request has one awaited response. |
| “A hooked function was just called.” | send() | The event originates asynchronously in the target. |
| “The agent rejected an incoming command.” | send() | A diagnostic may occur independently of a host RPC call. |
| “Install this probe and report its outcome.” | Usually RPC | The host needs a completion result before continuing. |
Frida provides RPC by exposing functions in the agent’s rpc.exports object. The host accesses these functions through script.exports; calls return promises because they cross the process boundary.
JavaScript API | Frida • A world-class dynamic instrumentation toolkit
Read Frida’s official explanation of host-agent RPC. It establishes the mechanics behind the typed wrapper you will build, including synchronous and asynchronous agent functions.
In the “Communication between host and injected process” section, read the RPC definition. Then inspect the Node.js example immediately below it, focusing on the agent’s rpc.exports object, await script.load(), and the host calls made through script.exports.
Two consequences matter for your tool:
- The agent must be loaded before an RPC call can succeed.
- The host must still treat the RPC return value as
unknownat runtime.
TypeScript can describe an expected return shape, but it cannot guarantee that the currently injected bundle is the version you believe it is, nor that an agent operation returned JSON-compatible data.
Define the RPC operation as a shared contract
Your message schemas already live in @grasp/frida-protocol. Keep RPC wire contracts there too. The package represents everything that can cross between host and agent; it remains independent of Frida’s Node bindings, terminal rendering, and application services.
Add packages/frida-protocol/src/rpc.ts:
import { z } from "zod";
import { PROTOCOL_VERSION } from "./messages.js";
export const TargetRuntimeInfoSchema = z
.object({
protocolVersion: z.literal(PROTOCOL_VERSION),
pid: z.number().int().positive(),
platform: z.literal("windows"),
architecture: z.enum(["ia32", "x64", "arm", "arm64"]),
pointerSize: z.union([z.literal(4), z.literal(8)]),
agentVersion: z.string().min(1).max(64),
})
.strict();
export type TargetRuntimeInfo = Readonly<
z.infer<typeof TargetRuntimeInfoSchema>
>;
Re-export it from the package’s public entry point:
export * from "./messages.js";
export * from "./rpc.js";
TargetRuntimeInfo is intentionally plain data. It does not contain a NativePointer, a JavaScript function, or a Frida Process object. Those values only make sense inside the injected process. The operation converts target-local state into a serializable contract at the boundary.
The schema also captures facts that are useful later:
pidproves which process reported the information.platformmakes this Windows-focused build explicit.architectureandpointerSizewill matter when decoding native arguments.agentVersionhelps identify host-agent bundle mismatches during development.protocolVersioncontinues the compatibility rule introduced in the previous lesson.
This is a query operation: it observes state and has no intended side effect. Keeping the first RPC operation small and deterministic makes it useful as a post-attach health check.
Expose getRuntimeInfo() from the agent
Add the schema to the agent’s imports and define an operation that returns only validated contract data.
import {
PROTOCOL_VERSION,
TargetRuntimeInfoSchema,
type TargetRuntimeInfo,
} from "@grasp/frida-protocol";
const AGENT_VERSION = "0.1.0";
function getRuntimeInfo(): TargetRuntimeInfo {
return TargetRuntimeInfoSchema.parse({
protocolVersion: PROTOCOL_VERSION,
pid: Process.id,
platform: Process.platform,
architecture: Process.arch,
pointerSize: Process.pointerSize,
agentVersion: AGENT_VERSION,
});
}
rpc.exports = {
getRuntimeInfo,
};
Place this alongside the agent.ready and recv() code from the previous lesson, rather than replacing that message handling. The resulting agent has two public communication surfaces:
send()plusrecv()for asynchronous protocol messages.rpc.exports.getRuntimeInfo()for direct host-initiated queries.
The TargetRuntimeInfoSchema.parse(...) call in the agent is a development guard. If an unsupported architecture string or malformed field somehow reaches this boundary, the agent operation fails loudly instead of silently exporting an invalid contract.
The host will validate the value again after it returns. That second validation is not redundant: these two runtimes can have different bundle versions, and Frida serializes data while moving it across the process boundary.
Build checkpoint: rebuild the agent bundle after changing the source.
pnpm run build:agentConfirm that the output bundle contains no Node.js-only imports. The agent runs inside the target process, not inside your CLI process.
Keep Frida’s dynamic RPC proxy inside an infrastructure adapter
Frida exposes script.exports as a dynamic RPC proxy. That is convenient, but letting it flow into the rest of the application would spread unchecked dynamic method calls through feature handlers.
Instead, isolate the dynamic part in a small Frida adapter. The adapter has three responsibilities:
- Verify that the required RPC method exists.
- Invoke it and convert rejected promises into a typed failure.
- Validate the returned value with the shared schema.
Create src/infrastructure/frida/read-target-runtime-info.ts:
import {
TargetRuntimeInfoSchema,
type TargetRuntimeInfo,
} from "@grasp/frida-protocol";
import {
err,
ok,
type Result,
} from "../../shared/result.js";
export type TargetRuntimeInfoError =
| {
readonly kind: "missing-agent-operation";
readonly operation: "getRuntimeInfo";
}
| {
readonly kind: "agent-operation-failed";
readonly operation: "getRuntimeInfo";
readonly detail: string;
}
| {
readonly kind: "invalid-agent-operation-result";
readonly operation: "getRuntimeInfo";
readonly issues: readonly string[];
};
type RuntimeInfoRpcExports = Readonly<{
getRuntimeInfo(): Promise<unknown>;
}>;
function hasGetRuntimeInfo(
value: unknown,
): value is RuntimeInfoRpcExports {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as {
readonly getRuntimeInfo?: unknown;
};
return typeof candidate.getRuntimeInfo === "function";
}
function describeCause(cause: unknown): string {
if (cause instanceof Error) {
return cause.message;
}
return "The RPC request was rejected with a non-Error value.";
}
function summarizeIssues(issues: readonly {
readonly path: readonly PropertyKey[];
readonly message: string;
}[]): readonly string[] {
return issues.slice(0, 3).map(function summarize(issue) {
const location =
issue.path.length === 0 ? "<root>" : issue.path.join(".");
return `${location}: ${issue.message}`;
});
}
export async function readTargetRuntimeInfo(
scriptExports: unknown,
): Promise<Result<TargetRuntimeInfo, TargetRuntimeInfoError>> {
if (!hasGetRuntimeInfo(scriptExports)) {
return err({
kind: "missing-agent-operation",
operation: "getRuntimeInfo",
});
}
let rawResult: unknown;
try {
rawResult = await scriptExports.getRuntimeInfo();
} catch (cause) {
return err({
kind: "agent-operation-failed",
operation: "getRuntimeInfo",
detail: describeCause(cause),
});
}
const parsed = TargetRuntimeInfoSchema.safeParse(rawResult);
if (!parsed.success) {
return err({
kind: "invalid-agent-operation-result",
operation: "getRuntimeInfo",
issues: summarizeIssues(parsed.error.issues),
});
}
return ok(parsed.data);
}
Adjust the Result, ok, and err import path to match the typed result utility created in Module 1. The essential point is that expected failures are values returned to the caller, not exceptions that accidentally terminate a REPL command.
Notice where the unavoidable dynamic typing is contained:
script.exports
enters the adapter as unknown. It becomes RuntimeInfoRpcExports only after the adapter has checked that getRuntimeInfo is callable. Its return value remains unknown until Zod validates it.
Outside this file, callers receive either:
- a fully validated
TargetRuntimeInfo, or - a structured
TargetRuntimeInfoError.
No application command handler needs to know that Frida represents exported methods through a dynamic proxy.
Invoke the operation after the agent is ready
Extend the authorized-fixture smoke runner from the previous lesson. Retain its existing sequence:
- Attach to the fixture process.
- Create the script.
- Register the message listener.
- Load the script.
- Await the validated
agent.readymessage.
Only after that readiness point, call the RPC adapter:
import { readTargetRuntimeInfo } from
"../infrastructure/frida/read-target-runtime-info.js";
// Existing setup from the previous lesson:
// const session = await frida.attach(pid);
// const script = await session.createScript(agentSource);
// script.message.connect(...);
// await script.load();
// await ready;
const runtimeInfoResult = await readTargetRuntimeInfo(script.exports);
if (!runtimeInfoResult.ok) {
throw new Error(
`Runtime inspection failed: ${JSON.stringify(runtimeInfoResult.error)}`,
);
}
const runtimeInfo = runtimeInfoResult.value;
if (runtimeInfo.pid !== pid) {
throw new Error(
`RPC reported PID ${runtimeInfo.pid}, but the host attached to PID ${pid}.`,
);
}
if (runtimeInfo.platform !== "windows") {
throw new Error(
`Expected a Windows target, received ${runtimeInfo.platform}.`,
);
}
console.log(
[
`Target PID: ${runtimeInfo.pid}`,
`Architecture: ${runtimeInfo.architecture}`,
`Pointer size: ${runtimeInfo.pointerSize} bytes`,
`Agent version: ${runtimeInfo.agentVersion}`,
].join("\n"),
);
A successful smoke run against the controlled fixture should now contain both the prior readiness confirmation and output similar to:
Agent 0.1.0 is ready in PID <fixture-pid>.
Target PID: <fixture-pid>
Architecture: x64
Pointer size: 8 bytes
Agent version: 0.1.0
Run the check using only the local fixture process compiled for this course:
pnpm run build:agent
pnpm exec tsx src/dev/attach-fixture.ts <fixture-pid>
The PID comparison is more than cosmetic. It verifies that both the asynchronous agent.ready protocol message and the RPC response describe the same attached target.
Decide what failures mean at this boundary
An RPC rejection is different from an invalid RPC response:
| Failure | Likely cause | Adapter result |
|---|---|---|
missing-agent-operation | A stale bundle or wrong agent was loaded. | The expected export is absent. |
agent-operation-failed | The agent threw, was unloaded, or the session disappeared while awaiting the call. | Frida rejected the RPC promise. |
invalid-agent-operation-result | The call returned, but its value violated the shared schema. | The host rejects the data at its boundary. |
At this stage, the smoke runner escalates every failure by throwing because it is an integration check. In the actual CLI and REPL, a command handler can render these result values as recoverable diagnostics where appropriate.
Do not catch an RPC failure and substitute guessed information such as the PID originally requested. That would hide an important distinction: the host may have attached successfully, but the current agent script may no longer be usable.
For future operations with expected target-level outcomes—such as “module not loaded” or “export not found”—prefer a typed result payload returned by the RPC method. Reserve thrown agent exceptions and rejected RPC promises for unexpected execution or transport failures.
Test the host adapter without Frida
The host adapter can be unit-tested using a small object that behaves like script.exports. No Windows process or injected agent is needed.
import { describe, expect, it } from "vitest";
import { readTargetRuntimeInfo } from "./read-target-runtime-info.js";
describe("readTargetRuntimeInfo", function () {
it("returns validated runtime information", async function () {
const scriptExports = {
async getRuntimeInfo(): Promise<unknown> {
return {
protocolVersion: 1,
pid: 4242,
platform: "windows",
architecture: "x64",
pointerSize: 8,
agentVersion: "0.1.0",
};
},
};
const result = await readTargetRuntimeInfo(scriptExports);
expect(result).toEqual({
ok: true,
value: {
protocolVersion: 1,
pid: 4242,
platform: "windows",
architecture: "x64",
pointerSize: 8,
agentVersion: "0.1.0",
},
});
});
it("rejects malformed values returned by an RPC export", async function () {
const scriptExports = {
async getRuntimeInfo(): Promise<unknown> {
return {
protocolVersion: 1,
pid: "not-a-pid",
platform: "windows",
};
},
};
const result = await readTargetRuntimeInfo(scriptExports);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.kind).toBe(
"invalid-agent-operation-result",
);
}
});
});
These tests focus on your code’s stable behavior, not Frida internals. The controlled fixture integration check then verifies the one part a test double cannot: that rpc.exports in the injected bundle and script.exports in frida-node communicate correctly.
Key takeaways
Frida RPC gives your host a direct, awaitable interface to carefully chosen agent operations:
- The agent exposes operations through
rpc.exports; the Node.js host invokes them throughscript.exports. - RPC is best for bounded request-response work. Validated
send()messages remain necessary for asynchronous trace events and diagnostics. TargetRuntimeInfoSchemais a shared wire contract, not an assumption based solely on TypeScript types.- The agent validates what it emits, and the host validates what it receives.
- A Frida-specific dynamic RPC proxy is contained in one infrastructure adapter rather than leaked into command handlers.
- Missing exports, rejected RPC requests, and malformed return values become distinct typed failures.
Next, you will make the session lifecycle resilient when the script is destroyed or the target detaches. That work will ensure RPC operations, message listeners, and cleanup paths remain safe even when the target process exits unexpectedly.
Can't find a good explanation? Sign up and we'll make it for you
Sign up