An admin shell for server-rendered Go apps. Your app brings its pages and its nav. toolshell brings everything around them: the sidebar, the header, the stylesheet, one script, the charts and the error pages. It has no dependencies outside the standard library and needs no build step.
| Light, English | Dark, Arabic (right to left) |
|---|---|
![]() |
![]() |
- Layout. A sidebar that folds to icons and slides in on phones, and a header with a profile menu and a language menu.
- Navigation. A Ctrl/Cmd-K command palette and
gthen key jumps. - Languages. Pages translate with
{{.T "text"}}, and a language written right to left turns the whole layout. - Feedback. Tooltips and toasts. A blocking veil covers the page while a long form runs, and confirm dialogs guard dangerous buttons.
- Data. Sortable, searchable and paged tables, with CSV export that is guarded against spreadsheet formulas.
- Charts. Server-rendered SVG:
spark,donut,cols,ring,meterandbars. They need no JavaScript. - Themes. Light, dark and auto. The choice lives in a cookie, so the first paint is already right.
- Error pages. Styled pages for 404, 500 and any other status the app cannot serve.
- Security. A strict Content-Security-Policy with no
'unsafe-inline'. Every asset is a file under/_ts/.
go get github.com/zoolcoder/toolshell@latesttoolshell requires Go 1.26 or later.
ts := toolshell.New(toolshell.Config{
Brand: toolshell.Brand{Name: "mail", Accent: "ctl"},
Cookie: "mailctl",
User: toolshell.User{Name: "you"},
Langs: []toolshell.Lang{
{Code: "en", Name: "English"},
{Code: "ar", Name: "العربية", Dir: "rtl"},
},
Messages: map[string]map[string]string{
"ar": {"Domains": "النطاقات", "%d domains": "%d نطاقات"},
},
})
tmpl, err := ts.Templates(appFS, "templates/*.html", funcs)
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
ts.Mount(mux)
nav := []toolshell.NavGroup{{Items: []toolshell.NavItem{
{Key: "domains", Label: "Domains", Href: "/domains", Icon: "globe"},
}}}
mux.HandleFunc("GET /domains", func(w http.ResponseWriter, r *http.Request) {
page := struct {
toolshell.Chrome
Count int
}{ts.Chrome(r, "domains", nav), 3}
toolshell.Render(w, tmpl, http.StatusOK, "domains.html", page)
})Each page template starts and ends with the shell:
{{template "ts-top" .}}
<h1 class="pg">{{.T "Domains"}}</h1>
<p>{{.T "%d domains" .Count}}</p>
<div class="dial sm">{{donut "ok bad" (ints 7 2)}}</div>
{{template "ts-bottom" .}}The package documentation covers every type and template function.
Render executes the page into a buffer before it writes anything. A
template error therefore becomes the styled 500 page, never half a page.
Write ErrorPage for any other status.
{{.T "English text" args...}}looks the text up in the page's language, then formats it.{{.TH ...}}does the same for a sentence that carries markup. The translation is escaped and only the arguments are trusted.- The shell ships its own Arabic for its menus and dialogs. Strings the app supplies win over them.
- The chosen language is kept in the
<cookie>-langcookie. GET /lang?set=ar&back=/pathswitches the language.backis kept on this site.
In a right-to-left page, add class="flip" around an icon that points
forward, such as a "next" arrow, and class="back" around one that points
back. The icon mirrors only where it needs to.
SetHeaders sets the policy and the other strict headers that every page is
meant to carry. Call it from a middleware, or at the top of each handler. The
policy allows only the page's own origin. Keep inline <script> and style="" out of your
templates: put behaviour in data- attributes the shell's script already
reads, and put styles in classes.
Three subpackages hold the plumbing a local admin tool needs besides its pages. They need nothing beyond the standard library.
guardkeeps the page on this machine.ListenAndServerefuses a non-loopback address, then serves with timeouts and a graceful shutdown.Handlerrefuses a foreignHost(DNS rebinding) and a write started by another site (CSRF), sets the headers and caps the body.mcpis a stdio MCP server whose tools are the CLI's own commands, run in-process with an empty stdin. APlantool saves its plan and answers with aplan_id; anApplytool takes only that id.Skillsserves an embeddedskills/*/SKILL.mdtree as prompts.doctoris thedoctorreport: ok, warn or fail per check, as text or as versioned JSON.activityis the JSONL log of what the tool did, for its Activity page.
toolshell.Back answers a form post: it redirects to the form's return_to
with the outcome as the flash the next page shows.
err := guard.ListenAndServe(ctx, "127.0.0.1:0", handler, func(host string) {
fmt.Printf("ui on http://%s/\n", host)
})- mailctl: declarative mail domains,
and its local
mailctl uiadmin page.
See CONTRIBUTING.md. Report security issues privately, as described in SECURITY.md.

