Tool Calling & Agents · 3 to 4 hours
Extend a tool adapter with a search tool
Extend the validated ticket adapter with a paginated search_tickets tool behind a tool registry under a 3-hour cap, hand in tests and a README, then review a sample submission as the interviewer would.
Take-home brief
4 h
Used in interview rounds:
Take-home & Review (Medium)Overview
Rehearse a take-home: extend the validated ticket adapter with a paginated search tool under a 3-hour cap, hand in tests and a README, then review a sample submission as the interviewer would. Everything runs on an in-memory store, with no API keys or network calls.
Study time: 3 to 4 hours
Interview format: Take-home build with a 3-hour cap, then a 45-minute review
By the end you can
- Turn a take-home brief into a written contract, a plan, and a time budget, and record what you skip under the cap
- Extend a validated tool adapter with a second read-only tool behind an exact-name registry without changing the first tool's behavior
- Implement keyset pagination that applies the trusted tenant scope before the cursor, with a bounded page size and a correct next_cursor
- Write tests that run with one command and cover acceptance calls, failure paths, boundaries, page walks, and tenant isolation
- Write a README whose assumptions, decisions, limits, and next steps a reviewer can check against the code
- Review a submission as an interviewer would: rank issues by impact and back each comment with an input, a reason, and a fix
Prerequisites
Helpful background: Basic TypeScript, JavaScript, or Python, and a test runner you already use (for example Jest, Vitest, node:test, pytest, or unittest).
This topic covers workflows versus agents, typed tools, approval boundaries, state, and termination. Specific frameworks and protocols appear as tags.
How a take-home is judged
A take-home gives you a fixed scope, a starting point, and a time cap, then a review where an interviewer reads your work (usually README, then tests, then code) and asks you to defend it.
- Write the contract first. Where the brief is silent, choose an answer and record it as an assumption.
- Scope is graded. Build what the brief asks, nothing more: an unrequested feature is more code to check and defend.
- The cap is real. Finish the core path with its tests first; a smaller finished scope beats a larger broken one.
- The review tests judgment. Expect to walk through one call, defend a trade-off made under the cap, say how the feature would fail in production, answer comments on your code, and sometimes review someone else's.
The take-home brief
Worked example
Suppose the assistant team at a fictional help-desk company sends you this take-home. Their support assistant already reads one ticket at a time through the validated get_ticket adapter (the starting point below). Support agents now ask questions such as "which of my open tickets mention login?", which needs a search.
Task. Add a second read-only tool, search_tickets, that follows the search contract below. Move dispatch into a tool registry so that each tool has its own argument checks and handler, and keep get_ticket exactly as it is. Start from your solution to the validated adapter exercise, or write get_ticket from the table below first (about 15 minutes).
Time. Cap the build at 3 hours; the review is a separate 45-minute call. If time runs out, stop: hand in what works with every test passing (mark an unfinished test as skipped, with the reason), and list in the README what you skipped and how you would finish it.
In scope: executeTool with both tools, the in-memory fixture store, the search_tickets declaration the model receives (name, description, and argument schema), tests, and a README.
Out of scope: write tools, the model call and the orchestration loop, asynchronous code, a database, network calls, relevance ranking, and dependencies beyond a test runner.
Language. TypeScript, JavaScript, or Python. Tool arguments and results keep the same names in every language (ticket_id, next_cursor) because the model reads them; the trusted context follows each language's convention, as in the starting adapter.
Hand in:
- The adapter source.
- The
search_ticketsdeclaration. - Tests that run with one command and include every acceptance call (listed in the acceptance exercise).
- A README: how to run it, the contract you implemented, assumptions, decisions and their trade-offs, known limits, what you skipped, and next steps.
Review questions to expect in the 45-minute call:
- Walk through one call from
executeToolto a handler. Where would a third tool plug in? - Why is the cursor a ticket ID rather than an offset or a token that carries more state?
- Which test gives you the most confidence that one tenant cannot read another tenant's tickets?
- What did you skip under the cap, and why that?
- With a database behind the store, what changes in search and paging, and what could fail?
- What would you add with another day?
The starting point: get_ticket
Worked example
The starting adapter is the validated get_ticket tool, called as executeTool(name, args, context, records) in JavaScript or TypeScript and execute_tool(name, args, context, records) in Python.
nameandargscome from the model and are untrusted.argsis already parsed from JSON, but its shape is not guaranteed.contextis trusted and built from the authenticated session:{ tenant, canReadTickets }, or in Python{"tenant": ..., "can_read_tickets": ...}.recordsis aMap(Pythondict) from"tenant:ticket_id", such as"alpha:TKT-104", to the stored ticket. A stored ticket can carry internal fields besidesid,title, andstatus.
get_ticket runs these checks in order and returns at the first failure:
| Order | Code | When |
|---|---|---|
| 1 | unknown_tool | name is not exactly a registered tool name (before the refactor, "get_ticket") |
| 2 | invalid_arguments | args is not a plain object: null, undefined, an array, a string, or a number |
| 3 | invalid_arguments | args has any key other than ticket_id, or no ticket_id |
| 4 | invalid_arguments | ticket_id is not a string of TKT- followed by exactly three digits from 0 to 9 |
| 5 | forbidden | canReadTickets is false |
| 6 | not_found | records has no entry under context.tenant + ":" + ticket_id |
Success is { ok: true, ticket: { id, title, status } }, a fresh object with exactly those three fields copied from the stored ticket. Every failure is { ok: false, code }, returned, not thrown. A tenant argument is rejected even when it matches the trusted tenant, and the adapter never modifies records.
The search_tickets contract
Worked example
search_tickets lists the caller's tickets one page at a time. It is read-only and needs the same read permission as get_ticket.
Arguments. args must be a plain object. Every argument is optional, and {} is valid. A key that is present must hold a valid value, so null is invalid. Any other key, tenant included, is invalid.
| Argument | Valid values | When absent |
|---|---|---|
status | "open" or "closed" | No status filter |
query | A string of 1 to 40 characters, used as given (no trimming) | No text filter |
limit | An integer from 1 to 10. Booleans, fractions, and numeric strings are not integers | 5 |
cursor | A ticket ID in the get_ticket format | Start from the lowest ID |
Checks, in order, returning at the first failure: unknown_tool when the name is not registered; invalid_arguments when args is not a plain object, has a key outside the table, or holds an invalid value; forbidden when canReadTickets is false. A search never returns not_found.
Search, in this order:
- Take only the tickets whose store key starts with the trusted tenant followed by
:. - Sort them by
id, ascending. Every ID has the sameTKT-plus three digits format, so string order is numeric order. - If
statusis given, keep the tickets with that status. Ifqueryis given, keep the tickets whose title, lowercased, contains the lowercasedquery. Only the title is searched. - If
cursoris given, keep the tickets whoseidis greater than it. The cursor is a position: it does not have to be an existing ticket. - Return the first
limittickets.
Result. { ok: true, tickets: [{ id, title, status }, ...], next_cursor }. Each ticket is a fresh copy with exactly those three fields. next_cursor is the id of the last returned ticket when more matching tickets remain after it, otherwise null. An empty tickets list is a successful answer.
The fixture store
Worked example
The tests use one in-memory store with ten tickets in two tenants, listed in insertion order (not ID order). Each stored ticket reads as id, title, status, then any internal field. The first two rows are the starting adapter's fixtures.
| Store key | Stored ticket |
|---|---|
| alpha:TKT-104 | TKT-104, "Settings crash", open |
| beta:TKT-104 | TKT-104, "Private billing issue", closed |
| alpha:TKT-112 | TKT-112, "Settings page shows wrong time zone", open |
| alpha:TKT-101 | TKT-101, "Login fails after password reset", open, internal_note "Escalated by the on-call engineer" |
| beta:TKT-108 | TKT-108, "Login fails after SSO change", open, internal_note "Security review pending" |
| alpha:TKT-107 | TKT-107, "Login page slow on mobile", open |
| alpha:TKT-115 | TKT-115, "Password reset email delayed", closed |
| beta:TKT-103 | TKT-103, "Refund not received", open |
| alpha:TKT-102 | TKT-102, "CSV export times out", closed |
| alpha:TKT-110 | TKT-110, "Invoice PDF missing logo", closed, internal_note "Refund approved by finance" |
Alpha has seven tickets and beta three. Their ID ranges interleave, and both tenants have a TKT-104. The acceptance calls use three trusted contexts: alpha with read permission, alpha without it, and beta with it.
Dispatch through a registry
A registry keeps the tool boundary in one place: a table from each registered name to that tool's argument parser and handler, and one executor that applies the same order to every tool. It looks up the exact name (a miss is unknown_tool), runs that tool's parser (a failure is invalid_arguments), checks permission from the trusted context, then runs the handler.
Adding a tool means adding one entry. Share truly shared helpers (the plain-object check, the ticket ID pattern, the projection to public fields), never a rule that loosens one tool to suit another.
Exact names only. In JavaScript, tools[name] on a plain object also finds inherited names like constructor, so use a Map or an own-property check. In Python, use an explicit dictionary, never getattr, a dynamic import, or eval.
Refactor before you extend. Move the existing tool behind the registry and pass its old fixtures unchanged before adding the new tool. A regression in a tool that already worked costs more in review than a missing extra.
Pagination that cannot widen scope
A search returns a page, not everything: an unbounded list fills the model's context and loads the service. Give limit a default and a maximum, and say in the tool description how to get more.
| Approach | Next page | Weakness |
|---|---|---|
| Offset | Skip the first N matches | Inserts or deletes between calls shift later pages, so items repeat or go missing; large offsets still read skipped rows |
| Keyset | Continue after the last seen value of a unique sort key | Cannot jump to an arbitrary page; needs a unique, stable sort key |
Keyset pagination maps to an indexed query and does not drift. Some APIs use an object ID as the cursor, such as Stripe's starting_after.
A cursor is untrusted input. The server wrote it, but it comes back through the model, which can change it or be steered to by text inside a ticket. Validate it like any other argument and use it only as a position inside a scope already taken from the trusted context: every function that uses client input to reach records needs its own authorization check (OWASP API1:2023).
Encoding is not protection. A model can decode an encoded cursor, edit it, and encode it again. If a cursor must carry state, such as its filters, sign it and reject one that does not match the request. A valid signature only proves your server issued the cursor; another session can still send that same genuine cursor, so scope must come from the context.
Say when to stop. Return a continuation value only when more results remain; a database can tell by fetching one row more than the limit. An empty page is an answer, not an error.
Plan before you build
Spend the first 15 minutes of the cap here, before any code.
Design exercise
Plan before you build
Before you write code, plan the take-home as you would in its first 15 minutes, using the brief, the starting point, the search contract, and the fixture store.
Requirements
- Every acceptance call passes and get_ticket is unchanged
- A new tool needs one registry entry and no change to other tools
- No argument value can change whose tickets are read
Constraints
- 3-hour build cap, including tests and the README
- No dependencies beyond a test runner
Deliverables
- Contract notes for the README, including decisions where the brief is silent
- The registry shape and the order of checks
- The search_tickets declaration the model receives
- A test list grouped by what each group proves
- How you will split the 3 hours, and what you will drop first if time runs short
Build the extension
Build it in your own editor, with your own tests.
Code exercise
Build the extension
This is the take-home itself, so there is no practice workspace: build it in your own editor, in TypeScript, JavaScript, or Python, with your own tests. Implement executeTool with get_ticket and search_tickets behind a registry, load the fixture store, and make the tests run with one command. The reference implementation behind the reveal runs every acceptance call and prints its result; open it only after your own suite passes.
Expected
Every acceptance call returns what the search contract defines, get_ticket behaves exactly as in the starting point, following next_cursor from any first page returns each of the caller's matching tickets once and in ID order, and no result carries a field other than id, title, and status.
Limitations
The store is an in-memory snapshot keyed by a string composite key, which is safe only because the tenant comes from trusted context and IDs are validated; a database would use a tenant-qualified query with an index on tenant and ID. Lowercasing is reliable for ASCII titles, and string length is counted in each language's own units, so non-ASCII queries can differ between languages. There is no model call, orchestration, concurrency, or write path.
Predict the acceptance results
Predict each result before you write its test.
Predict the outcome
Predict the acceptance results
Each row is one call against the fixture store. Predict the result before you reveal it: the error code, or the ticket IDs in order and next_cursor. Every returned ticket carries only id, title, and status. Your tests must include all thirteen calls.
| Call | Trusted context | Tool and arguments | Expected result |
|---|---|---|---|
| A1 | alpha, can read | get_ticket {"ticket_id": "TKT-104"} | ? |
| A2 | alpha, can read | search_tickets {} | ? |
| A3 | alpha, can read | search_tickets {"cursor": "TKT-110"} | ? |
| A4 | alpha, can read | search_tickets {"status": "open", "query": "LOGIN"} | ? |
| A5 | alpha, can read | search_tickets {"cursor": "TKT-108", "limit": 2} | ? |
| A6 | alpha, can read | search_tickets {"cursor": "TKT-107", "limit": 3} | ? |
| A7 | alpha, can read | search_tickets {"status": "closed", "limit": 10} | ? |
| A8 | alpha, can read | search_tickets {"query": "refund"} | ? |
| A9 | alpha, can read | search_tickets {"limit": 11} | ? |
| A10 | alpha, can read | search_tickets {"status": "open", "tenant": "alpha"} | ? |
| A11 | alpha, cannot read | search_tickets {} | ? |
| A12 | beta, can read | search_tickets {"cursor": "TKT-102", "limit": 2} | ? |
| A13 | alpha, can read | list_tickets {} | ? |
Tests and a README a reviewer trusts
Derive tests from the contract, not your code; each should fail if the code were broken.
- One rule, one failing value. A value that breaks only that rule, both sides of each bound, and a call that fails two checks at once to show which wins.
- Acceptance as a table. Every acceptance call in one table-driven test, compared as whole results so a stray field fails it.
- Properties across pages. Follow the continuation value until it stops and check that every matching ticket appears exactly once, in order, with filters and small limits too.
- Isolation with a second tenant. Use a tenant whose IDs overlap yours and whose titles match your queries, and send cursors from its ID range. One tenant cannot show isolation.
- Regression. The starting tool's original fixtures, unchanged.
The suite runs with one command, needs no keys or network, and is deterministic. The README says how to run it, the contract implemented, assumptions where the brief was silent, decisions and their trade-offs, known limits, what was skipped, and next steps. Every claim must be true of the code: the reviewer will try it.
Write review comments that get acted on
Read a submission as a reviewer does: the README's claims, then what the tests assert and omit, then each path that handles a model-controlled value.
- Rank by impact. A model-controlled value that changes whose data is read comes first, then data that leaves the boundary or changes the store, then contract breaks and crashes, then missing evidence, then documentation and style. Mark blocking comments and label minor ones ("Nit:", "Optional:").
- Make each comment actionable. Name the line or behavior, give one input that shows the problem, say why it matters, and suggest a fix with the test that would have caught it. Comment on the code, not the person, and say what was done well.
- Three comments, not thirty. Lead with what blocks the merge; list the rest in one line each.
Review a sample submission
Review it the way the interviewer will review yours.
Diagnose
Review a sample submission
A candidate handed in the submission below, and its tests pass with npm test. get_ticket is unchanged from the starting adapter and not shown. The excerpts are the registry and search_tickets, the test file, and the README.
// tools.ts (excerpt)
const TOOLS = new Map<string, (args: any, context: Context, records: Records) => object>([
["get_ticket", getTicket], // unchanged from the starting adapter
["search_tickets", searchTickets],
]);
export function executeTool(name: string, args: any, context: Context, records: Records) {
const tool = TOOLS.get(name);
if (!tool) return { ok: false, code: "unknown_tool" };
return tool(args, context, records);
}
function searchTickets(args: any, context: Context, records: Records) {
if (typeof args !== "object" || args === null || Array.isArray(args)) {
return { ok: false, code: "invalid_arguments" };
}
if (args.status !== undefined && args.status !== "open" && args.status !== "closed") {
return { ok: false, code: "invalid_arguments" };
}
if (!context.canReadTickets) return { ok: false, code: "forbidden" };
// The cursor is opaque, so the model cannot tamper with it.
let tenant = context.tenant;
let after = "";
if (args.cursor) {
const decoded = JSON.parse(Buffer.from(args.cursor, "base64").toString("utf8"));
tenant = decoded.tenant;
after = decoded.after;
}
const limit = args.limit ?? 5;
const query = (args.query ?? "").toLowerCase();
const matches = [...records.entries()]
.filter(([key]) => key.startsWith(tenant + ":"))
.map(([, ticket]) => ticket)
.filter((t) => args.status === undefined || t.status === args.status)
.filter((t) => t.title.toLowerCase().includes(query))
.filter((t) => t.id > after)
.sort((a, b) => (a.id < b.id ? -1 : 1));
const page = matches.slice(0, limit);
const more = matches.length > limit;
const last = page[page.length - 1];
return {
ok: true,
tickets: page,
next_cursor: more ? Buffer.from(JSON.stringify({ tenant, after: last.id })).toString("base64") : null,
};
}// search.test.ts
import { executeTool } from "./tools";
import { records } from "./fixtures";
const alpha = { tenant: "alpha", canReadTickets: true };
test("returns the first page", () => {
const result: any = executeTool("search_tickets", {}, alpha, records);
expect(result.tickets).toHaveLength(5);
});
test("returns the next page", () => {
const first: any = executeTool("search_tickets", {}, alpha, records);
const second: any = executeTool("search_tickets", { cursor: first.next_cursor }, alpha, records);
expect(second.tickets).toHaveLength(2);
});
test("filters by status", () => {
const result: any = executeTool("search_tickets", { status: "closed" }, alpha, records);
expect(result.tickets.every((t: any) => t.status === "closed")).toBe(true);
});README.md
Ticket tools. Run: npm install, then npm test.
- Added search_tickets with a status filter, a text search, and pagination.
- The cursor is opaque (base64), so the model cannot tamper with it.
- Dispatch now goes through a tool map.
Review the submission as the take-home reviewer. Write your three most important comments in priority order. For each, name the line or behavior, give an input from the fixture store that shows the problem, say why it matters, and suggest a fix with the test that would have caught it. Then list anything else you noticed in one line each, and name one thing the submission does well.
Completion checklist
- I can pass all thirteen acceptance calls and the starting adapter's fixtures in one test run.
- I can show with a test what a cursor taken from another tenant's ID range does in my design, and explain why.
- I can hand in a README that a reviewer can run and judge without asking me anything.
- I can review a submission with three prioritized comments, each with an input, a reason, and a fix.
- I can defend one trade-off I made under the 3-hour cap and say what would change it.
Sources
- OWASP API Security Top 10 2023: API1 Broken Object Level Authorization An authorization check in every function that uses client input to access a record, object IDs that arrive in the request payload, and authorization tests that block a change when they fail.
- Stripe API reference: Pagination Cursor-based list pagination: starting_after is an object ID that defines the place in the list, limit has a default and a range, and has_more says whether more elements remain.
- Use The Index, Luke: We need tool support for keyset pagination Offset pagination returns duplicates when rows are inserted between pages and costs more as offsets grow; the keyset (seek) method continues after the last seen row and cannot jump to arbitrary pages.
- Anthropic Engineering: Writing effective tools for AI agents Pagination, filtering, and truncation with sensible default parameter values for tools that can return large responses, and returning only high-signal fields (published September 11, 2025).
- Google Engineering Practices: How to write code review comments Comment on the code, not the developer; explain why; give guidance; label severity with Nit, Optional, and FYI.
- Google Engineering Practices: What to look for in a code review Tests should be correct, sensible, and useful, and fail when the code is broken; tell the developer what they did well.
The take-home brief, fixture store, acceptance calls, sample submission, review comments, and rubric are original synthetic teaching material about a fictional help-desk company. The adapter is provider-neutral: wrap the declaration in your provider's tool format and check its current documentation. The 3-hour cap, study times, and readiness thresholds are editorial estimates. Product, protocol, and model details are as of November 2026.
Related on CodeSnatch
Lessons, problems, and articles that build the skills this question uses.
AI Engineering
Practice tests that prove a second tenant's records stay out of what the model receives, as isolation tests here do.
System Design
Compares offset, cursor, and keyset pagination and the next-cursor response a list tool returns.
AI Engineering
Shows tool declarations and schema-driven argument checks, the pieces behind the search_tickets declaration.
Interview experience
A first-person take-home with a written time budget, a README of trade-offs, and the review call that followed.
Behavioral Interview
Gives a four-step way to state a trade-off and the cost you accepted, for defending a choice made under the cap.
Practice the interview question
Answer it in your own words before you look at the reference answer.
