Uplink is a Stacks application but keeps its own config shape and its own module layout, so it does not benefit from what the framework already provides. Two moves fix that: config files belong in ./config, and anything app code calls belongs where auto-imports can reach it.
Both have a trap that is invisible until runtime. Each is written out below, verified against the framework source rather than inferred.
1. Config
What will not work
Moving app/Uplink/config.ts to config/uplink.ts and expecting config.uplink from @stacksjs/config gives undefined.
@stacksjs/config does not scan config/. It walks a hardcoded list of about 45 [configKey, filename] pairs in storage/framework/core/config/src/overrides.ts (the userConfigs array), loads only those files, and merges them over the framework defaults. uplink is not in that list, and should not be added: that list is the framework's own config surface, not a registry for application config.
This failure is quiet. config.uplink is simply undefined, and the usual if (config.uplink?.pollMs) reads as "not configured" rather than as an error.
What to use instead
bunfig, which is what the Stacks family already uses for exactly this. stx resolves its own config the same way, with { name: 'stx', alias: 'ui' }.
import type { UplinkConfig } from './types'
import { loadConfig } from 'bunfig'
export const uplink = await loadConfig<UplinkConfig>({
name: 'uplink',
defaultConfig: { /* today's defaults, unchanged */ },
checkEnv: true,
})defaultConfigDirisresolve(process.cwd(), 'config'), soname: 'uplink'readsconfig/uplink.tswith no path wiring.- It deep-merges over
defaultConfigand is documented to always return at least the defaults, never null or undefined, so there is no "config missing" branch to write. - The config file itself is written the way every Stacks config file is written, which is also what makes it typed in an editor:
// config/uplink.ts
import type { UplinkConfig } from '../app/Uplink/types'
export default {
allowed: [],
pollMs: 2000,
// ...
} satisfies UplinkConfigEnv vars come free, and replace hand-rolled reads
With checkEnv: true, bunfig's applyEnvVarsToConfig derives the variable name from the config name plus the key path: the prefix is name.toUpperCase(), and each camelCase segment is split on capitals and upper-cased. So pollMs becomes UPLINK_POLL_MS, maxChars becomes UPLINK_MAX_CHARS, ackAfterMs becomes UPLINK_ACK_AFTER_MS. A legacy flat form (UPLINK_POLLMS) is also accepted.
That subsumes the manual UPLINK_* handling in app/Uplink/settings.ts, which is the "different way of config loading" worth removing. Check the derived names against the ones already documented in .env.example before switching, and keep any that differ as explicit overrides rather than renaming what users have set.
One dependency note
bunfig ships inside better-dx, which uplink currently has as a devDependency. This is a runtime import, and the Stacks convention in AGENTS.md is explicit about that case: a package that imports one of better-dx's tools at runtime declares it as a real dependency. So add bunfig to dependencies rather than relying on the dev bundle.
The trap, for framework config specifically
This one applies to reads of @stacksjs/config, not to bunfig. config.<key> returns framework defaults until await overridesReady resolves, because the override object is mutated in place asynchronously to avoid a module cycle. Any framework config uplink reads at module scope in a background process gets defaults, not the application's values. If uplink reads config.app, config.logging or similar, await overridesReady first.
2. Auto-imports
What is actually injected
On the server, injectGlobalAutoImports() puts these on globalThis:
- every model,
- everything exported from
app/Jobs/andapp/Controllers/, - everything exported from
resources/functions/, plus the framework defaults' functions.
The generated declaration is storage/framework/types/server-auto-imports.d.ts. Regenerate with buddy generate (--types for the declarations). A new file added without regenerating is a typecheck failure, not a silent one, so this is the cheap half.
So Chris is right that resources/functions/ is the place for support code app code calls. Class exports are fine; the barrel re-exports whatever the module exports.
The trap, and it is the important part of this issue
A declaration is not an injection. The globals exist only after injectGlobalAutoImports() runs, and that call happens at server boot (buddy serve, storage/framework/core/buddy/src/production-server.ts). It is also guarded to run once per process.
Uplink's engine does not run there. It runs in a launchd background service and in app/Commands/Uplink.ts. If that process never calls injectGlobalAutoImports(), then a bare CodexEngine(...) in service code typechecks and throws ReferenceError at runtime, because the ambient declaration exists while the runtime binding does not.
This is not hypothetical. It is stacksjs/stacks#2585: a declaration surface that named symbols nothing injected, which typechecked cleanly and took whole pages down unhydrated in production. The fix there was to stop declaring what is not injected.
So pick one deliberately:
- Either have the service boot through the path that injects, and verify it,
- or keep explicit imports in service code and use auto-imports only where code runs inside the server: routes, actions, jobs, the dashboard.
Explicit imports are not a workaround here. They are what the framework itself does, and AGENTS.md says so: the built-in actions and models import Action, route, response, schema and the rest from their packages even though those names are declared as globals.
Verify at runtime, not in the editor
After moving anything, prove the binding exists in the process that uses it:
buddy generate --types- confirm the name appears in
storage/framework/types/server-auto-imports.d.ts - in the service process, log
typeof (globalThis as any).CodexEngineonce at startup
Step 3 is the one that catches the failure above. Steps 1 and 2 pass in the broken case.
Two smaller rules
- Name collisions are skipped, not reported. A name that would shadow a built-in is not injected. This is why the framework's
ErrorandRequestmodels must be imported directly. Avoid exporting a name likeRequest,ResponseorErrorfromresources/functions/. - Browser side is a different surface. stx templates get what the stx runtime attaches to
window.storage/framework/browser-auto-imports.jsonis compile-time only and does not determine what the browser has, which is the other half of #2585. A TypeScript module imported by an stx client script must import everything explicitly, because entry bindings do not leak into bundled module scope. Relevant toapp/Uplink/dashboard.tsandapp/Uplink/menubar.html.
Suggested split for the current files
app/Uplink/config.ts→ values move toconfig/uplink.ts; theUplinkConfiginterface stays in a types module and the config filesatisfiesit.app/Uplink/settings.ts→ theUPLINK_*reading collapses into bunfig'scheckEnv.codex-engine.ts,engine.ts,format.ts,filter.tsand similar →resources/functions/, subject to the runtime check above.messages-db.ts,typedstream.ts,sqlite-store.tsand the rest of the service plumbing can stay where they are. Auto-import is for what app code reaches for casually; making every internal module a global costs namespace and gains nothing.