Cygnus X-1
A first-class plugin runtime, plus the great papercut purge — context helpers, builder guards, tighter types, and the breaking changes to go with them.
The "We Stopped Mutating the Client Behind Your Back" Release
Here's the dirty secret nobody talked about: "extending Seyfert" meant cracking open the Client, welding on some properties, and hoping the next library in line wouldn't rip them off. It worked. It also aged like milk left on a Caracas rooftop.
Seyfert v5.0.0 ships a real, typed plugin runtime — lifecycle, ordering, requirements, teardown, the whole ceremony — and then, because we were already elbow-deep in the codebase, we went on a crusade against roughly a hundred papercuts you'd quietly learned to live with.
You're welcome.
First-Class Plugins
This is the headline. Read it twice if you need to.
Seyfert now has an actual plugin API instead of "set some fields on the client and hope." A plugin can add or remove commands, components and modals, register middlewares, extend the client and context, add cache resources and lang overlays, observe REST and command execution, wrap gateway traffic, expose shared services, and emit diagnostics. Basically everything you used to do by hand — except now there are types, a lifecycle, and the confidence that two plugins won't knife each other in the dark.
Say you want to ship a reusable economy feature — a service on the client, a middleware that guards balances, and a connection that opens on start and closes on shutdown.
const balanceGuard = createMiddleware(({ next }) => next());
// Packaging it meant a setup function that performs surgery on the client.
function useEconomy(client: Client) {
// Bolt a service on — untyped, so every `client.economy` read is `any`.
(client as any).economy = new EconomyService();
// Merge a middleware in and hope nothing else clobbered the object.
client.middlewares = {
...client.middlewares,
balanceGuard,
};
// Connect before use? Wire it yourself. Close it on shutdown?
// ...also yourself. There was no teardown — you just had to remember.
}
const client = new Client();
useEconomy(client); // and call this, in the right order, in every botconst balanceGuard = createMiddleware(({ next }) => next());
const economyPlugin = createPlugin({
name: 'economy',
// Typed client extension — `client.economy` is real and inferred now.
client: { economy: () => new EconomyService() },
register(api) {
api.middlewares.add('balanceGuard', balanceGuard, { global: true });
},
setup: client => client.economy.connect(),
teardown: client => client.economy.close(), // cleanup that actually runs
});
const client = new Client({ plugins: definePlugins(economyPlugin) });That before/after is the whole pitch. Package a feature once, get types and a real lifecycle, and never perform open-heart surgery on the Client again.
Plugins have their own tab in the docs — lifecycle, ordering, shared services, extensions, gateway and REST interceptors, diagnostics — the full picture lives there. We're not reprinting the manual here. Go read it. It's good.
The Great Papercut Purge
With the plugin work already tearing the hood open, we looked around and thought: while we're in here…
What followed was a reckoning with every rough edge we'd been stepping on since v2. Here are the ones worth showing.
Lowercase option keys
Discord rejects non-lowercase option names. That's a fact you used to discover at deploy time, staring at a 400 response. Seyfert now rejects them at compile time — in your editor, before Discord ever sees it.
const options = {
// Looks fine. Fails only once the command upload hits Discord.
User: createStringOption({ description: 'User id', required: true }),
};const options = {
// v5 yells at you here, in your editor, before Discord ever sees it.
user: createStringOption({ description: 'User id', required: true }),
};Red squiggly > 400 response. Every time.
Autocomplete choice types
Autocomplete choices are now tied to the option type. Numeric options reject string values; string options reject numeric ones. The days of 'four' on an integer option — and Discord silently disagreeing — are over.
createIntegerOption({
description: 'Roll a die',
autocomplete(interaction) {
// 'four' on an integer option. Discord disagreed. Quietly.
interaction.respond([{ name: 'D4', value: 'four' }]);
},
});createIntegerOption({
description: 'Roll a die',
autocomplete(interaction) {
interaction.respond([{ name: 'D4', value: 4 }]);
},
});Context helpers that respect your time
Half of working with a context used to be tunneling through ctx.interaction for things the context should've just handed you. We looked at the repetition and felt embarrassed. Now it does.
// Modals, member/channel fetches, modal fields, updates, message id —
// all routed through ctx.interaction (or the raw client), every time.
await ctx.interaction.modal(profileModal, { waitFor: 1_000 });
const member = ctx.guildId
? await ctx.client.members.fetch(ctx.guildId, ctx.author.id)
: undefined;
const channel = await ctx.client.channels.fetch(ctx.channelId);
const username = ctx.interaction.getInputValue('username', true);
await ctx.interaction.update({ content: 'Saved' }, true);
const messageId = ctx.interaction.message.id;const submitted = await ctx.modal(profileModal, { waitFor: 1_000 });
// Both take 'cache' | 'rest' | 'flow' with mode-aware return types
// ('cache' is sync, the rest return a Promise).
const member = await ctx.fetchMember('flow');
const channel = await ctx.channel('flow');
if (submitted) {
const username = submitted.getInputValue('username', true);
await submitted.update({ content: 'Saved' });
const messageId = submitted.message.id;
}Prefix contexts throw a clear Cannot use modal without an interaction. instead of failing in a confusing way, modal contexts expose every field accessor (channels, roles, users, files, radios, checkboxes…), and component contexts keep interaction.values typed and guards like isStringSelectMenu() useful.
Builders that fail before Discord does
Builders used to happily serialize half-built junk and let the API reject it. You'd ship a poll with no question, a modal with no title, and Discord would be the one to tell you. Embarrassing for everyone involved.
Now toJSON() checks its own work.
// No question, one answer. Discord will reject this — eventually.
new PollBuilder()
.setAnswers({ text: 'Yes' })
.toJSON();// Missing question or empty answers now throw on toJSON().
new PollBuilder()
.setQuestion({ text: 'Deploy?' })
.setAnswers({ text: 'Yes' }, { text: 'No' })
.toJSON();Modal, MediaGalleryItem, and StringSelectOption validate the same way, invalid hex colors fail early, and RadioGroupOption now requires its data up front instead of being an empty shell you fill in later:
const option = new RadioGroupOption(); // an empty option, validated by vibes
const group = new RadioGroup().setCustomId('topics').setOptions(option);const group = new RadioGroup()
.setCustomId('topics')
.setOptions(
new RadioGroupOption({ label: 'General', value: 'general' }),
new RadioGroupOption({ label: 'Other', value: 'other' }),
);StringSelectMenu also accepts raw API option objects now, and a typed StringSelectMenu<'value'> rejects raw values outside its union. Validated by the compiler, not by vibes.
Moderation and messages, with the IDs removed
Moderation helpers stopped speaking raw Discord (delete_message_seconds) and started speaking Seyfert. Poll and member-search helpers stopped making you pass the same IDs you already had. Small changes. The kind you feel in your fingertips every day.
// Raw snake_case, positional reason, and IDs you already have on hand.
await member.ban({ delete_message_seconds: 60 }, 'cleanup');
await member.timeout(10); // 10 = ten *seconds*, if you remembered the unit
await client.messages.endPoll(channelId, messageId);
await client.messages.getAnswerVoters(channelId, messageId, 1);
await client.members.search(guildId); // no query? sure, here's everyone-ishawait member.ban({ deleteMessageSeconds: 60, reason: 'cleanup' });
await member.timeout(10_000, 'spam'); // 10_000 ms = ten seconds, plus a reason
await message.endPoll();
const voters = await message.getAnswerVoters(1, true); // validates the id locally
await client.members.search(guildId, { query: 'alice', limit: 5 });Voice state also grew booleans — isMuted, isDeafened, isCameraOn, isStreaming, isSuppressed — so you stop writing voiceState.mute || voiceState.selfMute for the hundredth time.
Collections that keep their keys
Collection.filter(...) and find(...) preserve type guards, and filterCollection(...) returns a real Collection with keys intact — no more rebuilding one from entries().
const active = new Collection(
[...members.entries()].filter(([, member]) => member.active),
);const active = members.filterCollection(member => member.active);LimitedCollection.values() and entries() now hand you plain values; use rawValues() / rawEntries() when you actually want the expiration metadata. It also rejects NaN limits while keeping 0, negative, and Infinity behavior. Because NaN as a collection limit was never a feature — it was a cry for help.
REST observers and inline intents
Instrumenting REST used to mean overwriting onSuccessRequest and hoping nothing else wanted it too. Last assignment wins. Two libraries instrumenting REST? Good luck.
Now you register isolated observers — and they stack.
// Last assignment wins. Two libraries instrumenting REST? Good luck.
client.rest.onSuccessRequest = (method, url) => {
metrics.increment('rest.success', { method, url });
};const dispose = client.rest.observe({
onRequest({ method, url }) { /* about to fire */ },
onSuccess({ method, url, response }) {
metrics.increment('rest.success', { method, url, status: response.status });
},
onFail({ method, url, error }) { /* it didn't go well */ },
onRatelimit({ method, url, response }) { /* Discord said slow down */ },
});
dispose(); // unsubscribe whenever you're doneFour hooks — onRequest, onSuccess, onFail, onRatelimit — all with readonly, lazily-built payloads, and isolated failures (one throwing observer doesn't take down the rest). Plugin observers run first, then direct observers, then legacy callbacks.
And because typing GatewayIntentBits.Guilds got old three major versions ago, intents accept strings inline now:
await client.start({ connection: { intents: GatewayIntentBits.Guilds } });await client.start({ connection: { intents: ['Guilds'] } });Pick your style. We don't judge.
Formatter helpers that know what they return
Formatter grew genuinely typed template-literal helpers, and timestamp(...) finally defaults to relative time instead of a style nobody picked on purpose.
// Hand-rolled strings, no type safety, and a timestamp default of "whatever".
const block = `>>> ${notes}`;
const emoji = `<:${name}:${id}>`;
Formatter.timestamp(date); // defaults to a fixed style, not relativeFormatter.blockQuote(notes); // typed `>>> ${string}`
Formatter.emojiMention(id, name); // typed `<:name:id>`
Formatter.channelLink(channelId); // typed ChannelLink
Formatter.timestamp(date); // now defaults to 'R' (relative time)quote, blockQuote, emojiMention, and channelLink return narrow template-literal types now, and timestamp(...) also accepts millisecond numbers. Small thing, but your editor finally autocompletes the shape. That's the whole point.
Typed groups, middlewares, and locales
A few new typed building blocks that make the daily grind less grindy: defineGroups(...) declares subcommand groups in a reusable, inferred object; middlewares('auth', 'rateLimit') captures middleware names with inference instead of a loose string[]; and preferGuildLocale flips ctx.t to resolve the guild locale before the user's.
const mws = middlewares('auth', 'rateLimit'); // names are inferred, not stringly-typed
const client = new Client({
// Reach for the guild's language first, fall back to the user's.
langs: { preferGuildLocale: true },
});Everything else, by subsystem
The rest of the hundred. Not big enough for a code sample each, too real to bury in a "various fixes" line. Grouped so you can scan to your subsystem and move on.
Commands, middleware & decorators
- A middleware that throws or rejects now reaches
onInternalErrorwith the original error. Onlystop('reason')is treated as a denial. - A denial's
metadata.middlewarenow names the middleware that actually calledstop(), not whichever one sat at the current index. command.middlewaresis areadonlytuple now — mutating it in place (.push(...)) is a type error. As it should be.Group(groupsDef, key)gained an overload that type-checks the key against a groups-definition object.Options(...)gained a(new () => SubCommand)[]overload alongside the lowercase-record one.InferMiddlewares<T>extracts the middleware-name union from amiddlewares(...)tuple.- New exported group types:
GroupDefinition,GroupDefinitions,LocalizedGroupDefinition,TranslatedGroupDefinition. - Command-locale resolution warns (
Locale key "x" resolved to nothing …) on missing keys instead of shipping blanks, and stopped mutating the shared aliases array.
Contexts
GuildCommandContextnarrowschannel(...)to guild channels andfetchMember(...)to a non-undefined member; newGuildCommandChanneltype backs it.write(...)/editOrReply(...)returnvoidunlessfetchReplyistrue— the false branch is no longer a fuzzyvoid | Message | …union.- Component contexts gained a
StringSelectValuesgeneric and actx.messagegetter, andisButton()now readsinteraction.componentType(wasinteraction.data.componentType). - Modal contexts gained the full accessor set:
getChannels,getRoles,getUsers,getMentionables,getRadioValues,getCheckbox,getCheckboxValues,getInputValue,getFiles, plusupdate(...)anddeferUpdate(). ComponentCommand.onInternalError/ModalCommand.onInternalErrornow receive the offendingcomponent/modalinstance beforeerror.
Builders & structures
ComponentCollectorStopReasonis exported now.GuildMember.roles.keysisreadonly,roles.highest()resolves toundefinedwhen there are no roles, andmanageable()parallel-fetches and returnsfalseon incomplete role data.GuildRole.edit(body, reason?)takes an audit-log reason.MessagesMethods.list(fetchOptions?)made its query optional, and embed serialization accepts anytoJSON()-bearing object, not justEmbedinstances.BitField.keys()returns typed(keyof T)[]instead ofstring[], andhas(...)/Permissions.has/strictHasaccept a single bit or an array.PermissionsBitField.resolve(...)throwsTypeErroron an unknown permission string instead of silently resolving to nothing.BaseInteraction.reply()tracks an in-flight_repliedPromiseand throwsINTERACTION_ALREADY_REPLIEDon a double-reply race; edits await the pending reply first.
Cache
keys()/values()/count()accept'*'to query across every guild or channel scope at once, andbulkGet(...)infers its result shape from the key tuples you pass.GuildBasedResource.flush(guild)lost its default — pass'*'to flush everything — whileGuildRelatedResource.flush(guild = '*')gained one.
Shorters & utilities
ChannelShorter.pins(...)returnspinnedAtas an epoch-ms number, not an ISO string.resolveColor(...)throws on a malformed#hexstring instead of quietly producingNaN.OAuth2URLOptions.permissionsis optional now.- New exported utility types
MakePresent,PickPresent,NonNullish,PropWhen;ResolverProps.embedsalso acceptsInMessageEmbed. SeyfertError.is(error, code)narrows a caught error by code ('CANNOT_USE_MODAL','SEYFERT_CONFIG_LOAD_ERROR', …).
Events
createEvent(...)andClientEvent.runreturnAwaitable<unknown>instead ofany, so handlers can return useful values without fighting the type system.- Custom (non-gateway) event handlers no longer receive a trailing
shardId. - Throwing inside a
onceevent resets it so it can fire again, andVOICE_CHANNEL_STATUS_UPDATEhands you a resolvedVoiceChannel | undefinedinstead of a raw cache promise.
Langs
- Lang files warn instead of failing silently on a missing
defaultexport, accept a single named-object export as a fallback, and skip ambiguous or invalid files with explicit warnings.
Client & runtime
- A bad token throws
INVALID_TOKEN(at client start and per request) instead of a generic assertion failure. - HTTP interactions answer
PINGwithPONGdirectly, before the command handler runs. - A failed
seyfert.configload throwsSEYFERT_CONFIG_LOAD_ERRORwith the original error preserved ascause. - The client logger is configurable via
new Client({ logger: { … } }), and the setting is now honored on worker clients too. UserAvatarDefaultnarrowed fromnumberto0 | 1 | 2 | 3 | 4 | 5.
REST
- App code (not just plugins) can register observers via
client.rest.observe(...), andRestArgumentsRequiredQueryis exported. - Rate-limit retries preserve the full request —
query,files,token,appendToFormData— instead of dropping fields on the way back into the queue. Fun bug.
New root exports
Importable by name now: BanOptions, ChannelLink, MessageLink, Timestamp, TimestampStyle, EmbedColors, HeadingLevel, OAuth2URLOptions, SeyfertError, SeyfertErrorCode, SeyfertErrorMessages, createValidationMetadata, PropWhen, StructPropState, StructStates, LimitedCollectionData, GuildRole, BotConfig, HttpConfig, ClientMiddlewares, AllClientEvents, CollectorRunParameters, Collectors, ParseClientEventName, ShardData, ShardManagerOptions, WorkerData, WorkerManagerOptions, WorkerInfo, WorkerShardInfo, Logger, LogLevels, LoggerOptions, CustomizeLoggerCallback, and AssignFilenameCallback.
Breaking Changes
The price of progress. Every change below is a before → after you can migrate against, with a one-line note on what moved and why.
Client augmentation
Extending the Client moved off two hand-maintained interfaces and onto one central SeyfertRegistry:
declare module 'seyfert' {
interface UsingClient extends ParseClient<Client<true>> {}
interface RegisteredMiddlewares extends ParseMiddlewares<typeof middlewares> {}
interface SeyfertRegistry {
client: ParseClient<Client<true>>;
middlewares: typeof middlewares;
}
}UsingClient, RegisteredMiddlewares, DefaultLocale and the plugin list are all derived from SeyfertRegistry now — augment its client / middlewares / langs / plugins keys instead of each type by hand.
import { type ParseMiddlewares } from 'seyfert';
interface RegisteredMiddlewares extends ParseMiddlewares<typeof middlewares> {}
interface SeyfertRegistry { middlewares: typeof middlewares } ParseMiddlewares is gone — createMiddleware(...) already returns the right shape, so the bare typeof middlewares is enough.
import type { APIGuildCreateOverwrite } from 'seyfert/lib/types';
import type { RESTAPIGuildCreateOverwrite } from 'seyfert/lib/types'; Pure rename, to match the REST* naming of the rest of the type surface.
Middlewares
pass() is gone — stop() with no arguments now skips the interaction silently.
const guard = createMiddleware(({ next, stop, pass }) => {
const guard = createMiddleware(({ next, stop }) => {
if (ignore) return pass();
if (ignore) return stop(); // skip silently, no error
if (blocked) return stop('Not allowed'); // stop with an error — unchanged
return next();
});stop() with no arguments replaces pass() (skip and do nothing); stop('reason') still stops execution and reports the error to the handler.
Moderation
Bans, timeouts and search stopped speaking raw Discord and started requiring the query you already had.
await member.ban({ delete_message_seconds: 60 }, 'cleanup');
await member.ban({ deleteMessageSeconds: 60, reason: 'cleanup' }); reason moved inside the options object and the key is camelCase; the same { deleteMessageSeconds, reason } shape applies to GuildBan.create(...), MemberShorter.ban(...) and BanShorter.create(...).
await member.timeout(10); // 10 = ten seconds
await member.timeout(10_000, 'spam'); // milliseconds now, + reasontimeout(...) takes milliseconds, not seconds, plus an optional reason.
await client.members.search(guildId);
await client.members.search(guildId, { query: 'alice' }); The query is required now.
Interactions & builders
await ctx.interaction.deferUpdate(true);
await ctx.interaction.deferUpdate(); The withResponse argument is gone.
new RadioGroup().setOptions([optionA, optionB]); // positional array
new RadioGroup().setOptions(optionA, optionB); // rest paramsRadioGroup.setOptions(...) and StringSelectMenu.setOptions(...) take rest params instead of an array.
const opt = new RadioGroupOption();
const opt = new RadioGroupOption({ label: 'A', value: 'a' }); The empty constructor is gone — pass the data up front.
const msg = await ctx.editResponse(body); // ComponentContext only
const msg = await ctx.editOrReply(body, true); // pass fetchReply for the MessageComponentContext.editResponse(...) was removed; editOrReply(...) returns void unless you pass fetchReply. (ModalContext.editResponse still exists.)
if (await ctx.interaction.replied) {}
if (ctx.interaction.replied) {} BaseInteraction.replied is a plain boolean now, not a pending-operation promise.
const keys: string[] = permissions.keys();
const keys = permissions.keys(); // (keyof typeof PermissionFlagsBits)[]BitField.keys() returns typed keys, not string[].
new PermissionsBitField().resolve(bits); // instance method
PermissionsBitField.resolve('NotAPerm'); // silent no-op
PermissionsBitField.resolve('Administrator'); // static only; throws on bad inputThe instance resolve(...) was removed; the static one throws on unknown strings and on negative / unsafe-integer inputs.
const json = new Modal().toJSON();
// returned a partial object
// throws now if required fields (custom_id, title) are missingtoJSON() on Poll, Modal, MediaGalleryItem, StringSelectOption and RadioGroupOption throws when required fields are missing.
options = { MyOption: createStringOption({ /* ... */ }) };
options = { myoption: createStringOption({ /* ... */ }) }; Command option keys must be lowercase, and a typed StringSelectMenu<'value'> rejects out-of-union raw values.
selectMenu.disabed;
selectMenu.disabled;
await entryPoint.withReponse();
await entryPoint.withResponse(); Two public typos fixed (old spellings removed): .disabled on select-menu builders, and EntryPointInteraction.withResponse(...).
Collections, cache & utilities
const ts: bigint = snowflakeToTimestamp(id);
const ts: number = snowflakeToTimestamp(id); // unix msReturns a number (unix ms), not a bigint.
cache.roles.flush();
cache.roles.flush('*'); GuildBasedResource.flush(...) has no default — pass '*' to clear everything.
for (const meta of limited.values()) {} // yielded { value, expire }
for (const value of limited.values()) {} // plain values now
for (const meta of limited.rawValues()) {} // metadata lives herevalues() / entries() yield plain data; rawValues() / rawEntries() carry the metadata. LimitedCollection also throws on a NaN limit.
const res = cache.bulkGet(keys); // full union result
const res = cache.bulkGet([['users', id]]); // result inferred from the keysCache.bulkGet(...) infers its result shape from the keys you pass; BulkGetKey is the new key type.
cache.bulkGet([['bans', userId]]);
cache.bulkGet([['bans', userId, guildId]]); bans is a guild-based resource now, so its key needs the guildId.
const at: string = pin.pinnedAt; // ISO string
const at: number = pin.pinnedAt; // unix msChannelShorter.pins(...) returns pinnedAt as a number, and resolveColor('#zzz') throws on a malformed #hex now.
const m = (await channel.members()).get(id); // Collection
const m = (await channel.members()).find(x => x.id === id); // arrayVoiceChannel.members(...) resolves to a GuildMemberStructure[], not a Collection.
const img = await resolveImage('https://x/404.png'); // read the error body
const img = await resolveImage('https://x/ok.png'); // throws on non-OK nowresolveImage(...) / resolveAttachmentData(...) throw INVALID_ATTACHMENT_TYPE when a remote URL responds non-OK.
adapter.relationships.get(id)?.includes(x); // string[]
adapter.relationships.get(id)?.has(x); // Set<string>MemoryAdapter.relationships is a Set now — only matters if you read it from a custom adapter.
cache.hasPrenseceUpdates;
cache.hasPresenceUpdates; Getter typo fixed.
Events, collectors & runtime
client.collectors.run('MESSAGE_CREATE', filter); // raw gateway name
client.collectors.run('messageCreate', filter); // camelCase client nameUse the camelCase client event name, not the raw gateway name.
import type { CollectorRunPameters } from 'seyfert';
import type { CollectorRunParameters } from 'seyfert'; Typo fixed.
client.collectors.run('messageCreate', filter);
// only the first matching collector ran
// every collector whose filter matches now runsThe early break is gone — tighten your filters if you relied on first-match-wins.
createEvent({ data: { name: 'myEvent' }, run(payload, client, shardId) {} });
createEvent({ data: { name: 'myEvent' }, run(payload, client) {} }); Custom (non-gateway) handlers no longer get a trailing shardId; run is typed Awaitable<unknown>.
createEvent({
data: { name: 'VOICE_CHANNEL_STATUS_UPDATE' },
run(payload) {}, // raw cache value
run(channel) {}, // resolved VoiceChannel | undefined
});VOICE_CHANNEL_STATUS_UPDATE hands you a resolved VoiceChannel | undefined now.
onInternalError(client, error) {}
onInternalError(client, component, error) {} ComponentCommand.onInternalError / ModalCommand.onInternalError gained a component / modal parameter before error.
client.setServices({ handleCommand: new HandleCommand(client) });
client.setServices({ handleCommand: HandleCommand }); ServicesOptions.handleCommand takes the HandleCommand constructor, not an instance.
new WorkerManager({ /* ... */ });
new WorkerManager({ mode: 'threads', path: './bot.js' }); // or mode: 'custom' + adapterWorkerManagerOptions is a discriminated union over mode — custom needs an adapter, threads / clusters need a path.
new WorkerClient({ onShardDisconnect() {} });
// listen to the shard disconnect / reconnect events insteadWorkerClientOptions.onShardDisconnect / onShardReconnect (and the Client equivalents) are deprecated.
type UserAvatarDefault = number;
type UserAvatarDefault = 0 | 1 | 2 | 3 | 4 | 5; UserAvatarDefault narrowed to 0 | 1 | 2 | 3 | 4 | 5.
Upgrade
pnpm install [email protected]Five major versions in, and Seyfert finally lets you extend it without performing surgery on the Client. The plugins are the future; the hundred papercuts are the part you'll feel tomorrow morning.
Not every release reinvents the framework. This one just made it yours.
Seyfert v5.0.0 — plugins in the front, papercuts in the back.