How a Floating Pet Plugin Exposes the UI Extension Model Inside DeepSeek Harness
Abstract: In the plugin ecosystem of AI programming tools, everyone is writing "tool-type plugins." But if you want to change the interface skin or add a floating pet, you need to write a different kind—a client-side UI plugin. This article uses my open-source DSH salted-fish pet plugin as an example, breaking down its two-layer entry points, slot injection, and several front-end engineering pitfalls encountered: background transparency, the containing-block trap of frosted glass, and penetrating the host DOM.
Figure: Server-side tool plugins hide behind the scenes for model invocation, while client-side UI plugins float on the interface for the user—the latter is injected via the shell.overlay slot, stepping over three pitfalls: background transparency, the frosted-glass trap, and DOM penetration.
You stare at the interface of an AI programming tool for eight hours a day, yet its appearance is decided by someone else. Want to change the wallpaper or add a floating pet? You can't find an entry in the settings—because tool vendors simply don't leave room for this kind of "skin-layer" modification.
DeepSeek Harness (hereafter dsh) leaves an entry: everything is a plugin. But most tutorials on plugins teach you how to register a tool for the model to call. The other kind of plugin that operates on the interface—client-side UI plugins—is rarely discussed. This article uses my own open-source dsh salted-fish pet plugin as an example, thoroughly breaking it down from entry points and slots to several front-end pitfalls.
Where a Plugin Runs Determines Its Type
dsh plugins fall into two categories, distinguished not by function but by where the code runs and whom it serves:
- Server-side tool plugins: The default form, running on the Node side. It registers a tool (e.g.,
greet), which the model knows about via its description and knows how to call via its input parameter schema. My August 17th article covered this type. - Client-side UI plugins: Declare
platform: 'web', run in the browser, and inject a React component into a UI slot. It registers no tools, consumes no Cordis services, and purely works on the interface.
In one sentence: the former adds capabilities to the model, the latter adds personality to the interface. The salted-fish pet is the latter—it doesn't write code for you, it just keeps you company while you slack off.
Why a Single Plugin Needs Two Entry Points
The most counter-intuitive part: this plugin has two entry files.
src/index.ts is the host (Node) entry, and the entire file is one line:
export function apply(): void {}
Empty. It exists solely so the Cordis Loader recognizes it as a valid plugin—allowing it to enter the host's cordis.yml plugin tree. It does nothing on the server side.
The real logic is in src/client/index.ts, exposed via the exports mapping in package.json:
{
"exports": {
".": { "default": "./lib/index.js" },
"./client": { "default": "./lib/client.js" }
},
"dsh": {
"client": {
"inject": ["@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-slots"],
"platform": "web"
}
}
}
exports["."] points to the host's empty apply; exports["./client"] points to the browser side. When dsh's client runtime reads dsh.client.platform: 'web', it knows to load the ./client version and prepare the client services it needs to inject (dsh-client-runtime, dsh-client-ui-slots).
The benefit of this split is very practical: the same npm package costs zero on the Node side (an empty apply introduces no React), and only truly loads the component on the browser side. You won't stuff a bunch of front-end dependencies into the server process just because you installed a UI plugin.
Slots: Mounting Components into the Interface
UI isn't just thrown into document.body and called done. dsh divides the interface into several slots, and plugins register components into designated slots.
The salted-fish plugin uses shell.overlay—a list-type slot mounted in the root scope (multiple plugins can add items to it). The plugin first declares its need for the slots service, then injects the component:
export const inject = ['slots'] as const
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'shell.overlay': { kind: 'list'; scope: 'root' }
}
}
export function apply(ctx: ClientContext) {
ctx.inject(['slots'], (scope) => {
const dispose = scope.slots.inject('shell.overlay', () =>
scope.slots.register({ name: 'shell.overlay', id: 'uiPet' }, SaltedFishPet),
)
return () => dispose()
})
}
slots.inject gets the slot registry, and slots.register binds a React component to the id uiPet. The salted fish and the wallpaper picker are two independent slot entries (uiPet and wallpaperPicker), each mounting its own component.
Registering into the Bundle: Behind a Single Command
During development, you clone the source and run install.sh; for regular users, it's a single line:
npx @deepseek-ai/dsh plugin --profile web add @tangyuewei/dsh-client-ui-pet
This command essentially does a pnpm add of this package in the persistent profile directory (~/.dsh/profiles/web) and appends its cordis.patch.yml to the bundle manifest. That patch file is extremely minimal:
- insert:
- id: ui-pet
name: '@tangyuewei/dsh-client-ui-pet'
When dsh starts, it overlays each plugin's patch in bundle order, injecting the salted fish into shell.overlay. The profile directory is unrelated to the npx cache; reopening the terminal or re-running npx still works—this is what makes it more stable than "manually patching the source every time."
Real running effect: default yu7 frosted-glass background with salted fish → open wallpaper panel → switch to Porsche 718 → 📌 pin (background image fullscreen pinned) → unpin → switch to Macan S (dark wallpapers also available, no light/dark restriction).
Cross-Slot State: How Two UI Pieces Link Up
The salted fish and the background are two independent slot entries, meaning they are not in the same React component tree—they can't directly pass props or share context.
But the linkage "click to hide the salted fish, and the background should also retract" must work. The solution is to extract a module-level shared store (visibility.ts):
let hidden = false
const listeners = new Set<() => void>()
export function setPetHidden(next: boolean): void {
if (hidden === next) return
hidden = next
for (const listener of listeners) listener()
}
export function subscribePetHidden(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
A boolean plus a set of listeners. The pet button calls setPetHidden, and subscribers in the background module receive the notification and retract synchronously. The pet component itself uses useSyncExternalStore to connect to this store, so even if the slot is remounted, it still reads the same persistent visibility state, avoiding the scenario where "the pet is gone but the background remains."
I'll state the cost upfront: the summon button currently relies on finding a button on the page containing the text "Session log" to position itself. If the Shell structure changes, this may break. This is a known limitation, not a feature.
Wallpaper API: Drop an Image, It Takes Effect
Wallpapers don't distinguish between light and dark themes; users pick freely. Source images are dropped into src/client/wallpapers/, and the build script (build-wallpapers.mjs) automatically scales them to 1920px and base64-encodes them into bg-images.generated.ts (gitignored, regenerated on every build). Thus, "adding a wallpaper" equals "dropping a file," with no manual base64 typing.
Selection state is persisted using localStorage (key dsh-ui-pet.wallpaper) and broadcast via a CustomEvent on window:
export function setCurrentWallpaperId(id: string): void {
localStorage.setItem(STORAGE_KEY, id)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT, { detail: { id } }))
}
Both the background module and the picker subscribe to this event, refreshing simultaneously when the wallpaper changes, and the selection persists after refresh and restart.
A Few Front-End Pitfalls (The Cost of Architectural Implementation)
Getting the plugin to run relies on several targeted front-end techniques. They aren't glamorous, but missing even one would break the interface.
Background Transparency. The theme background is opaque by default and would cover the wallpaper on the body. The solution is to forcibly make the theme background color transparent, allowing the body's wallpaper to show through:
body.dsh-bg-glow,
body.dsh-bg-glow #root,
body.dsh-bg-glow #root * {
--dsw-alias-bg-base: transparent !important;
}
A single CSS variable with !important overrides the opaque background color set by the theme's background shorthand.
The Frosted-Glass Containing-Block Trap. To make the sidebar look like frosted glass, the instinct is to add backdrop-filter to the column. But backdrop-filter makes that element the containing block for its internal position: fixed descendants—the settings panel is rendered via a portal and would be trapped inside the 280px-wide column. Switching to isolation: isolate doesn't work either: it creates a stacking context, and Chrome clips fixed descendants within that context to its own overflow. The correct solution is to place the filter on a ::before pseudo-element with z-index: -1 to escape to the body's background layer:
body.dsh-bg-glow [class$="sidebarCol"]::before {
content: ''; position: absolute; inset: 0; z-index: -1;
-webkit-backdrop-filter: blur(26px) saturate(160%);
backdrop-filter: blur(26px) saturate(160%);
}
Both of these landmines are documented in MDN's backdrop-filter entry, but you only know they bite when you actually write the code.
Penetrating the Host DOM. The host's columns use CSS Modules, and class names are hashed (sidebarCol__abc123). I can't import the host component to add styles, so I have to use a suffix attribute selector to target the local name:
body.dsh-bg-glow [class$="sidebarCol"] { background: rgba(255,255,255,0.55) !important; }
[class$="sidebarCol"] matches class names ending with "sidebarCol," independent of the hash prefix. The cost is that the selector is written broadly; if the host changes its local naming rules in the future, this will need to follow.
Theme and Glow. A MutationObserver watches the body's data-ds-dark-theme attribute to switch between light and dark modes; the mouse-following glow only writes coordinates to two CSS variables, --bg-mx/--bg-my, handled by the compositor without triggering repaints—mousemove is listened to with passive, not blocking scroll.
Known Limitations: Benefits and Costs Given Together
This plugin isn't perfect, and I've written its flaws into the README's "Known Limitations":
- Zero Persistence: Pet position, satiety, and mood are all session-level memory, reset on refresh;
- No Configuration Panel: All parameters (decay speed, margins, size) require modifying the source code and rebuilding, not exposed to the user;
- Summon Button Relies on DOM Lookup, which may break if the Shell structure changes;
- Wallpapers Embedded as base64, no external requests; dynamic loading requires self-modification.
These aren't a bug list, but trade-offs made under the positioning of a "pure front-end display plugin." It was never intended to do server-side work, account systems, or cloud sync.
Closing
The biggest gain from writing this plugin wasn't having an extra salted fish, but being forced to thoroughly understand dsh's host/browser boundary, slot contract, and host DOM penetration.
Another form of pluginization lies right here: it doesn't just want to add capabilities to the model, but also personality to the interface. When an AI programming tool becomes something you stare at for eight hours a day, whether you can tweak it to your liking is itself an engineering problem. Client-side UI plugins are the least discussed piece of this puzzle—I hope this article fills that gap.
References
- Open-source repository tangyuewei/dsh-client-ui-pet, August 2026.
- MDN Web Docs,
backdrop-filter.
Author: 唐悦玮 | An engineer who started from the backend and expanded to full-stack using AI.