AI Engineering
/
Tool Calling & Agents
/

Extend a tool adapter with a search tool

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.

Medium
Hands-on

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:

  1. The adapter source.
  2. The search_tickets declaration.
  3. Tests that run with one command and include every acceptance call (listed in the acceptance exercise).
  4. 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:

  1. Walk through one call from executeTool to a handler. Where would a third tool plug in?
  2. Why is the cursor a ticket ID rather than an offset or a token that carries more state?
  3. Which test gives you the most confidence that one tenant cannot read another tenant's tickets?
  4. What did you skip under the cap, and why that?
  5. With a database behind the store, what changes in search and paging, and what could fail?
  6. 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.

  • name and args come from the model and are untrusted. args is already parsed from JSON, but its shape is not guaranteed.
  • context is trusted and built from the authenticated session: { tenant, canReadTickets }, or in Python {"tenant": ..., "can_read_tickets": ...}.
  • records is a Map (Python dict) from "tenant:ticket_id", such as "alpha:TKT-104", to the stored ticket. A stored ticket can carry internal fields besides id, title, and status.

get_ticket runs these checks in order and returns at the first failure:

OrderCodeWhen
1unknown_toolname is not exactly a registered tool name (before the refactor, "get_ticket")
2invalid_argumentsargs is not a plain object: null, undefined, an array, a string, or a number
3invalid_argumentsargs has any key other than ticket_id, or no ticket_id
4invalid_argumentsticket_id is not a string of TKT- followed by exactly three digits from 0 to 9
5forbiddencanReadTickets is false
6not_foundrecords 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.

ArgumentValid valuesWhen absent
status"open" or "closed"No status filter
queryA string of 1 to 40 characters, used as given (no trimming)No text filter
limitAn integer from 1 to 10. Booleans, fractions, and numeric strings are not integers5
cursorA ticket ID in the get_ticket formatStart 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:

  1. Take only the tickets whose store key starts with the trusted tenant followed by :.
  2. Sort them by id, ascending. Every ID has the same TKT- plus three digits format, so string order is numeric order.
  3. If status is given, keep the tickets with that status. If query is given, keep the tickets whose title, lowercased, contains the lowercased query. Only the title is searched.
  4. If cursor is given, keep the tickets whose id is greater than it. The cursor is a position: it does not have to be an existing ticket.
  5. Return the first limit tickets.

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 keyStored ticket
alpha:TKT-104TKT-104, "Settings crash", open
beta:TKT-104TKT-104, "Private billing issue", closed
alpha:TKT-112TKT-112, "Settings page shows wrong time zone", open
alpha:TKT-101TKT-101, "Login fails after password reset", open, internal_note "Escalated by the on-call engineer"
beta:TKT-108TKT-108, "Login fails after SSO change", open, internal_note "Security review pending"
alpha:TKT-107TKT-107, "Login page slow on mobile", open
alpha:TKT-115TKT-115, "Password reset email delayed", closed
beta:TKT-103TKT-103, "Refund not received", open
alpha:TKT-102TKT-102, "CSV export times out", closed
alpha:TKT-110TKT-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.

ApproachNext pageWeakness
OffsetSkip the first N matchesInserts or deletes between calls shift later pages, so items repeat or go missing; large offsets still read skipped rows
KeysetContinue after the last seen value of a unique sort keyCannot 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.

CallTrusted contextTool and argumentsExpected result
A1alpha, can readget_ticket {"ticket_id": "TKT-104"}?
A2alpha, can readsearch_tickets {}?
A3alpha, can readsearch_tickets {"cursor": "TKT-110"}?
A4alpha, can readsearch_tickets {"status": "open", "query": "LOGIN"}?
A5alpha, can readsearch_tickets {"cursor": "TKT-108", "limit": 2}?
A6alpha, can readsearch_tickets {"cursor": "TKT-107", "limit": 3}?
A7alpha, can readsearch_tickets {"status": "closed", "limit": 10}?
A8alpha, can readsearch_tickets {"query": "refund"}?
A9alpha, can readsearch_tickets {"limit": 11}?
A10alpha, can readsearch_tickets {"status": "open", "tenant": "alpha"}?
A11alpha, cannot readsearch_tickets {}?
A12beta, can readsearch_tickets {"cursor": "TKT-102", "limit": 2}?
A13alpha, can readlist_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

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.

Practice the interview question

Answer it in your own words before you look at the reference answer.