Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
e7530f8
feat: add `langsmith apps` command for Custom Apps
samecrowder Jul 9, 2026
4deab18
working
samecrowder Jul 10, 2026
7496e26
asdf
samecrowder Jul 10, 2026
d66b39d
changed lots of stuff last night
samecrowder Jul 10, 2026
ac11a4c
small bugs
samecrowder Jul 10, 2026
216fd88
slight improvement to sandboxed env
samecrowder Jul 10, 2026
23ddfa7
fix the AQ window rendering bug
samecrowder Jul 10, 2026
e3b9d89
remove langsmith apps get
samecrowder Jul 11, 2026
2db277f
better template for default apps
samecrowder Jul 11, 2026
2ef6902
a prettier starter app
samecrowder Jul 11, 2026
4522ee0
better styling of the wrapper
samecrowder Jul 13, 2026
00aff05
fix(apps push): recreate app when linked app_id was deleted server-side
samecrowder Jul 15, 2026
aa70bca
npm run watch streaming changes
samecrowder Jul 15, 2026
b7c64aa
asdf
samecrowder Jul 15, 2026
04fa940
remove queue name from the template
samecrowder Jul 15, 2026
f4c6b11
mode cli side (#184)
harisaiharish Jul 16, 2026
65fa997
annotation grid template
harisaiharish Jul 17, 2026
fc9b142
removed --type, only --template exists now
harisaiharish Jul 17, 2026
95b5ce0
coding agent template
harisaiharish Jul 17, 2026
a5f03ce
experiments template
harisaiharish Jul 17, 2026
204d964
remove blank
samecrowder Jul 17, 2026
ea74452
purge --type
samecrowder Jul 17, 2026
5e754e9
annotation-queue template pagination works now
samecrowder Jul 17, 2026
cb6dc1b
revamp coding dashboard template
harisaiharish Jul 17, 2026
b65ff86
annotation-queue template is g2g
samecrowder Jul 17, 2026
1c7e3c5
better styling of the wrapper
samecrowder Jul 17, 2026
c25ad25
split AGENTS.md into shared base + per-template fragments; fix SA9009…
harisaiharish Jul 17, 2026
b84be89
Merge remote-tracking branch 'origin/feat_apps_command' into feat_app…
harisaiharish Jul 17, 2026
28d4b45
half-polished experiment page
harisaiharish Jul 17, 2026
a8f4895
starting some improvements to the grid template
samecrowder Jul 17, 2026
4af8d66
idk
samecrowder Jul 18, 2026
e32a580
asdf
samecrowder Jul 18, 2026
b9b6fff
Much better spreadsheet look
samecrowder Jul 18, 2026
8c1c20b
bunch of stuff
samecrowder Jul 18, 2026
2adff9c
set up the compnoent library
samecrowder Jul 18, 2026
1685713
deleting some of the files now in the shared utils
samecrowder Jul 18, 2026
48eccc9
adds a test
samecrowder Jul 18, 2026
b31b9b6
backing up, working on all 4 templates at same time
samecrowder Jul 18, 2026
9453619
some good improvements to annotation-queue-grid
samecrowder Jul 18, 2026
d1cf080
still working on experiment-comparisongs
samecrowder Jul 18, 2026
b0e1e81
lots of different claude sessions cookin
samecrowder Jul 18, 2026
7bedf16
experiment .md file
samecrowder Jul 18, 2026
9b60eb5
projects-only in dashboard
harisaiharish Jul 19, 2026
de2c994
fix stale _shared comments
harisaiharish Jul 19, 2026
2e3fc82
build on the push command
samecrowder Jul 20, 2026
1ef0166
experiment comparison template is way better now
samecrowder Jul 20, 2026
22236a2
redo the coding-agent-dashboard template
samecrowder Jul 20, 2026
1a076cb
(allegedly) better AGENTS.md content
samecrowder Jul 20, 2026
5e3a0dc
add in the stats endpoints
samecrowder Jul 20, 2026
23d802f
handle duplicate app name
harisaiharish Jul 20, 2026
45ea31c
Merge remote-tracking branch 'origin/feat_apps_command' into feat_app…
harisaiharish Jul 20, 2026
6e26c30
Merge remote-tracking branch 'origin/main' into feat_apps_command
harisaiharish Jul 20, 2026
d5ad663
error when no api key
harisaiharish Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -139,13 +140,64 @@
body []byte
}

// HTTPError is returned by RawGet/RawPost/RawPatch/RawDelete when the server
// responds with a 4xx/5xx status. Its Error() text matches the plain
// "HTTP <code>: <message>" format these methods have always returned; the
// StatusCode field lets callers detect specific codes (e.g. 404) via
// errors.As instead of parsing the message.
type HTTPError struct {
StatusCode int
Body []byte
}

func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, formatHTTPErrorBody(e.Body))
}

// IsNotFound reports whether err is an HTTPError with a 404 status.
func IsNotFound(err error) bool {
var httpErr *HTTPError
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
}

func IsConflict(err error) bool {
var httpErr *HTTPError
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusConflict
}

type httpErrorBody struct {
Error string `json:"error"`
Message string `json:"message"`
ErrorDescription string `json:"error_description"`
Detail any `json:"detail"`
}

// rejectMethodChangingRedirect is the Client's http.Client.CheckRedirect.
// Go's default redirect policy silently downgrades POST/PATCH/PUT/DELETE to a
// bodyless GET on a 301/302/303 (per RFC 7231 — the POST/Redirect/GET
// pattern some servers use intentionally). LangSmith's API is a plain JSON
// REST surface with no such intentional redirects, so the only time this
// client would ever see one is a misconfigured endpoint scheme (http instead
// of https, redirected to https by the server). Following it silently turns
// a write into a no-op GET — sometimes still 2xx, if a same-path GET happens
// to exist — which is far worse than a clear error. Same-method redirects
// (a GET following an http->https upgrade, say) are harmless and still
// followed.
func rejectMethodChangingRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
prev := via[len(via)-1]
if req.Method != prev.Method {
return fmt.Errorf(
"refusing to follow redirect that would change %s %s into %s %s"+
" (check LANGSMITH_ENDPOINT is using https, not http)",
prev.Method, prev.URL, req.Method, req.URL,
)
}
return nil
}

// doHTTP is the shared low-level helper used by RawDo and rawRequest.
func (c *Client) doHTTP(ctx context.Context, method, path string, body io.Reader, extraHeaders http.Header) (*httpResponse, error) {
url := c.apiURL + path
Expand All @@ -169,8 +221,11 @@
req.Header[k] = vals
}

httpClient := &http.Client{Timeout: 30 * time.Second}
httpClient := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: rejectMethodChangingRedirect,
}
resp, err := httpClient.Do(req)

Check failure

Code scanning / CodeQL

Uncontrolled data used in network request Critical

The
URL
of this request depends on a
user-provided value
.
if err != nil {
return nil, fmt.Errorf("HTTP %s %s: %w", method, path, err)
}
Expand Down Expand Up @@ -226,7 +281,7 @@
}

if resp.statusCode >= 400 {
return fmt.Errorf("HTTP %d: %s", resp.statusCode, formatHTTPErrorBody(resp.body))
return &HTTPError{StatusCode: resp.statusCode, Body: resp.body}
}

if result != nil {
Expand Down
49 changes: 49 additions & 0 deletions internal/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -632,3 +632,52 @@ func TestAPIURL(t *testing.T) {
t.Errorf("expected http://localhost:1234, got %q", c.APIURL())
}
}

// ---------- Redirect handling ----------

func TestRawDo_RejectsMethodChangingRedirect(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/feedback" {
http.Redirect(w, r, "/feedback-redirected", http.StatusFound) // 302
return
}
t.Errorf("redirect target should never be reached for a rejected redirect, got %s %s", r.Method, r.URL.Path)
}))
defer ts.Close()

c := New("key", ts.URL)
_, _, _, _, err := c.RawDo(context.Background(), "POST", "/feedback", strings.NewReader(`{"key":"note"}`), nil)
if err == nil {
t.Fatal("expected an error, got nil")
}
if !strings.Contains(err.Error(), "would change") {
t.Errorf("expected error to mention the method change, got: %v", err)
}
}

func TestRawDo_FollowsSameMethodRedirect(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/runs" {
http.Redirect(w, r, "/runs-redirected", http.StatusFound) // 302
return
}
if r.Method != "GET" {
t.Errorf("expected GET on redirect target, got %s", r.Method)
}
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer ts.Close()

c := New("key", ts.URL)
status, _, _, body, err := c.RawDo(context.Background(), "GET", "/runs", nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != 200 {
t.Errorf("expected 200, got %d", status)
}
if string(body) != `{"ok":true}` {
t.Errorf("unexpected body: %s", body)
}
}
210 changes: 210 additions & 0 deletions internal/cmd/apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package cmd

import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
)

// appsLinkDir and appsLinkFile name the local file that records which
// remote custom app a directory is linked to, mirroring how `vercel link`/
// `netlify link` avoid needing a server-side unique name to upsert against.
const (
appsLinkDir = ".langsmith"
appsLinkFile = "app.json"
)

const (
appsMaxFileEntries = 500
appsMaxFileSizeBytes = 1 << 20 // 1MB per file, matches hub push's limit.
)

// appLink is the contents of <dir>/.langsmith/app.json. It is the durable
// record of "what this app is" that `apps push` reads on every run to decide
// whether to create a new custom app or update the one it created last time.
type appLink struct {
AppID string `json:"app_id"`
Name string `json:"name"`
}

// customApp mirrors the JSON shape returned by smith-go's
// /v1/platform/custom-apps endpoints (smith-go/customapps/types.go).
type customApp struct {
ID string `json:"id"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Files map[string]string `json:"files,omitempty"`
Entrypoint string `json:"entrypoint"`
IsEnabled bool `json:"is_enabled"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
CreatedBy *string `json:"created_by,omitempty"`
}

var appsExcludedDirs = map[string]bool{
".git": true,
"node_modules": true,
".langsmith": true,
".cache": true,
".next": true,
}

var appsExcludedFiles = map[string]bool{
".DS_Store": true,
"Thumbs.db": true,
}

func newAppsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "apps",
Short: "Build, upload, and manage Custom Apps",
Long: `Build, upload, and manage Custom Apps — UIs you build locally against the
LangSmith API and run inside LangSmith.

A custom app is a small set of files rendered in a locked-down sandbox: no
direct network access, no npm modules at runtime — only a postMessage
bridge (window.langsmith.call/setData) proxied through the viewer's own
LangSmith session. Use React/JSX or npm dependencies freely; the scaffolded
starter bundles them locally (Vite) into the single dependency-free file the
sandbox expects.

Pick a starter with --template (blank, annotation-queue,
annotation-queue-grid); they differ only in the UI they scaffold — every app
is uploaded and rendered the same way.

Examples:
langsmith apps init --name my-app
langsmith apps init --name my-queue-app --template annotation-queue
langsmith apps dev
langsmith apps push
langsmith apps list
langsmith apps delete <app-id> --yes

All of the above (except list/delete, which take an app ID) act on the
current directory — cd into your app's directory first.`,
}
cmd.AddCommand(newAppsInitCmd())
cmd.AddCommand(newAppsDevCmd())
cmd.AddCommand(newAppsPushCmd())
cmd.AddCommand(newAppsListCmd())
cmd.AddCommand(newAppsDeleteCmd())
return cmd
}

// readAppLink reads <dir>/.langsmith/app.json. It returns (nil, nil) if the
// file does not exist — callers treat that as "not linked yet".
func readAppLink(dir string) (*appLink, error) {
path := filepath.Join(dir, appsLinkDir, appsLinkFile)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var link appLink
if err := json.Unmarshal(data, &link); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
return &link, nil
}

// writeAppLink writes <dir>/.langsmith/app.json, creating the directory if
// needed.
func writeAppLink(dir string, link appLink) error {
linkDir := filepath.Join(dir, appsLinkDir)
if err := os.MkdirAll(linkDir, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", linkDir, err)
}
data, err := json.MarshalIndent(link, "", " ")
if err != nil {
return fmt.Errorf("marshaling app link: %w", err)
}
data = append(data, '\n')
path := filepath.Join(linkDir, appsLinkFile)
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
return nil
}

// readDirectoryAsAppFiles walks root and returns a flat relative-path →
// content map suitable for custom_apps.files. Unlike hub's equivalent
// (readDirectoryAsFiles in hub_push.go), there is no {type, content}
// wrapper — the custom-apps API takes a plain map[string]string — and
// .langsmith/ itself is always excluded so the link file never gets
// uploaded as an app file.
func readDirectoryAsAppFiles(root string) (map[string]string, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("opening %s: %w", root, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory", root)
}

files := make(map[string]string)
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
base := d.Name()
if d.IsDir() {
if appsExcludedDirs[base] {
return filepath.SkipDir
}
return nil
}
if d.Type()&fs.ModeSymlink != 0 {
return nil
}
if appsExcludedFiles[base] || strings.HasPrefix(base, ".env") {
return nil
}

rel = filepath.ToSlash(rel)

data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s: %w", rel, err)
}
if len(data) > appsMaxFileSizeBytes {
return fmt.Errorf("file %s is %d bytes; exceeds %d-byte limit", rel, len(data), appsMaxFileSizeBytes)
}
if isAppFileBinary(data) {
return fmt.Errorf("file %s appears to be binary; custom apps do not support binary assets yet", rel)
}

files[rel] = string(data)
if len(files) > appsMaxFileEntries {
return fmt.Errorf("directory contains more than %d files; reduce before pushing", appsMaxFileEntries)
}
return nil
})
if err != nil {
return nil, err
}
return files, nil
}

func isAppFileBinary(data []byte) bool {
n := len(data)
if n > 512 {
n = 512
}
return bytes.IndexByte(data[:n], 0) != -1
}
Loading
Loading