Skip to content

Antipatterns to avoid

These are the patterns that make agents pick the wrong tool, call it with wrong args, or fail to recover from errors. Each one is paired with a fix. Drawn from Anthropic’s Writing Tools for Agents plus practical experience from the Lelemon Agentes connectors.

1. Vague descriptions / overlapping purposes

The smell:

defineTool({
name: 'search_users',
description: 'Search users.',
...
});
defineTool({
name: 'list_users',
description: 'List users.',
...
});

The agent has no way to choose. It either picks one at random, calls both, or asks the user to clarify (wasted turn).

Fix: consolidate into one tool with a discriminating parameter, or differentiate the descriptions so each one’s trigger is obvious.

defineTool({
name: 'users_query',
description:
'Find users. Pass `query` for free-text search across name/email; ' +
'leave empty to list the most-recent. Returns up to 50 per page.',
input: z.object({
query: z.string().optional(),
limit: z.number().int().min(1).max(50).default(20),
}),
...
});

2. Returning everything, unfiltered

The smell:

handler: async () => {
const all = await db.contacts.findAll(); // 47,000 rows
return { contacts: all };
};

The response is enormous. The agent burns tokens parsing it. The model’s context window fills with mostly irrelevant data.

Fix: require pagination + filters in the schema. Truncate proactively. Tell the agent in the response when there’s more.

input: z.object({
query: z.string().optional(),
limit: z.number().int().min(1).max(100).default(50),
cursor: z.string().optional(),
}),
output: z.object({
contacts: z.array(Contact),
nextCursor: z.string().optional(),
totalApprox: z.number().optional(),
}),
handler: async (input) => {
const page = await db.contacts.find(input.query, { limit: input.limit, cursor: input.cursor });
return {
contacts: page.items,
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
...(page.totalApprox ? { totalApprox: page.totalApprox } : {}),
};
},

3. Ambiguous parameter names

The smell:

input: z.object({
user: z.string(), // a name? an email? an id?
type: z.string(), // of what?
data: z.record(z.any()), // good luck
}),

The agent is forced to guess. It picks one interpretation and is wrong half the time.

Fix: name parameters by the thing they identify, not by their abstract role. Add .describe() with format hints.

input: z.object({
userId: z.string().uuid().describe('User UUID, e.g. "550e8400-e29b-..."'),
documentType: z.enum(['invoice', 'receipt', 'credit_note']).describe('SII tax document type'),
lineItems: z.array(LineItem).min(1).describe('At least one item; net unit price + quantity per row'),
}),

If a field is Record<string, unknown>, the agent is reading your mind. Replace it with a typed object every time you can.

4. Hidden side effects

The smell:

defineTool({
name: 'update_user',
description: 'Update a user.',
handler: async (input) => {
await db.users.update(input);
await sendEmail(input.email, 'profile changed'); // surprise
await audit.log({ kind: 'sensitive_change', userId: input.id }); // surprise
},
});

The agent doesn’t know the email goes out. It calls update_user to fix a typo and a customer gets a passive-aggressive notification.

Fix: put every side effect in the description. Offer a dry_run if the action is destructive or expensive.

defineTool({
name: 'update_user',
description:
'Update a user. Sends a "your profile changed" email to the user and ' +
'writes an audit log entry. Pass `notify: false` to skip the email; ' +
'audit log is always written.',
input: z.object({
userId: z.string().uuid(),
patch: UserPatch,
notify: z.boolean().default(true),
}),
...
});

Mute-by-default is generally a worse default than notify-by-default — but the agent needs to know either way.

5. Schemas without per-field descriptions

The smell:

input: z.object({
status: z.enum(['p', 'v', 'd', 'c', 'f', 'r']),
amount: z.number(),
currency: z.string(),
}),

status: 'c' — committed? cancelled? closed? The agent guesses. The model hallucinates currency: 'pesos' when the API expects 'CLP'.

Fix: describe every non-obvious field. Cap free-form strings with enum or regex if the upstream API does.

input: z.object({
status: z
.enum(['pending', 'verifying', 'done', 'committed', 'failed', 'rejected'])
.describe('Khipu payment status — see https://docs.khipu.com/api/payments'),
amount: z.number().positive().describe('Amount in the major unit (CLP pesos, USD dollars)'),
currency: z.enum(['CLP', 'USD']).describe('ISO 4217 code, only CLP and USD supported'),
}),

6. Tools that are too granular

The smell:

get_user_email(userId);
get_user_name(userId);
get_user_role(userId);
get_user_created_at(userId);
get_user_last_login(userId);

To answer “what’s this user’s role and last login,” the agent makes two calls. To render a profile, five.

Fix: group fetches that always travel together into one tool. Let the caller subset, not the agent.

defineTool({
name: 'users_get',
description: 'Fetch a user by id. Returns the full profile.',
input: z.object({ userId: z.string().uuid() }),
output: User, // includes email, name, role, createdAt, lastLogin, ...
...
});

The mirror-image antipattern is too coarse:

do_user_thing(userId, action: 'create' | 'update' | 'delete' | 'reset_password' | ...)

Don’t bundle unrelated operations behind a kind field. The right granularity is “one tool per logical operation, not one per HTTP endpoint and not one per micro-attribute.”

Bonus: the universal mistake

Returning a string when you could return an object.

// Bad
output: z.string(),
handler: async (...) => `Payment p_abc created; pay at https://... by 2026-05-12.`,
// Good
output: z.object({
paymentId: z.string(),
paymentUrl: z.string().url(),
expiresAt: z.string().datetime(),
}),
handler: async (...) => ({ paymentId: 'p_abc', paymentUrl: '...', expiresAt: '2026-05-12T00:00:00Z' }),

The agent can result.paymentUrl an object. It has to grep a string. Always return structured.

Next